18 Commits

Author SHA1 Message Date
68d4a9530c fix: 添加 alpine 国内镜像源
Some checks failed
Release / build (push) Has been cancelled
2026-04-27 06:21:42 +08:00
78c3ceccea fix: 修复 CI 构建时目录 busy 问题 2026-04-27 06:19:01 +08:00
5d9498f687 feat: 记忆体系统 v0.3.0 完成
Some checks failed
Release / build (push) Failing after 4m28s
## 核心功能
- 双记忆系统合并: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
2026-04-27 06:16:19 +08:00
88a110e87e fix: 恢复 markdown 渲染修复,应用配置系统重构
- 恢复 e070461 修复:wrap_width=-1 禁用换行,使用 lipgloss.Print
- 应用 dd3c8a0 配置重构:字段名变更(theme/line_width/user_icon)
- 添加用户配置文件支持(~/.config/hxclaw/config.yml)
- 添加 TTS 配置到用户配置(enabled, auto)
2026-04-26 06:44:47 +08:00
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
17 changed files with 2498 additions and 190 deletions

View File

@@ -13,20 +13,23 @@ jobs:
env:
GOPROXY: "https://mirrors.aliyun.com/goproxy/,direct"
steps:
- name: Setup mirrors
run: |
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
- name: Checkout
run: |
apk add git bash
rm -rf /tmp/hxclaw
git clone https://hub.gaomia.site/titor/hxclaw.git /tmp/hxclaw
cp -r /tmp/hxclaw/* /tmp/
cp -r /tmp/hxclaw/.* /tmp/ 2>/dev/null || true
rm -rf /workspace/hxclaw/hxclaw 2>/dev/null || true
git clone https://hub.gaomia.site/titor/hxclaw.git /workspace/hxclaw/hxclaw
cd /workspace/hxclaw/hxclaw
- name: Download dependencies
run: cd /tmp && go mod download
run: cd /workspace/hxclaw && go mod download
- name: Build
run: |
cd /tmp
cd /workspace/hxclaw
for p in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
os=${p%/*}
arch=${p#*/}
@@ -36,13 +39,13 @@ jobs:
done
- name: Checksums
run: cd /tmp && sha256sum hxclaw-* > checksums.txt
run: cd /workspace/hxclaw && sha256sum hxclaw-* > checksums.txt
- name: Release
env:
GITEA_TOKEN: "${{ secrets.release_token }}"
run: |
cd /tmp
cd /workspace/hxclaw
apk add curl jq
TAG_NAME="${GITHUB_REF#refs/tags/}"

187
agents.md
View File

@@ -41,36 +41,169 @@
## 当前任务
### v0.1.0 目标
### v0.3.0 目标
实现流式输出功能:
1. 创建 go.mod 配置依赖
2. 实现 main.go 入口
3. 实现流式 Provider 调用
4. 实时打印 token
5. 处理非流式 Provider 回退
6. Markdown 终端渲染glamour
实现聊天记忆体功能:
1. 集成 libSQL (TursoDB) 数据库
2. 实现会话维度的短期记忆
3. 支持向量检索(硅基流动 API
4. 命令行支持(/new, /memory, /sessions
---
## 实现进度
### 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 等配置
---
### v0.3.0 进度
1. **数据库层**
- 集成 libSQL (TursoDB)
- 创建 sessions 和 chats 表
- 实现 CRUD 操作
- 数据库保存在 `~/.config/hxclaw/hxclaw.db`
2. **独立上下文系统**
- 创建 GetContextPrompt() 返回会话摘要
- 注入到 ProcessDirect() 调用前
- 不再依赖 picoclaw session
3. **向量服务**
- 封装硅基流动 Embedding API
- 实现向量生成和相似度检索
4. **会话管理**
- 自动创建 Session首次输入时
- 手动创建(/new 命令)
- 消息摘要生成
5. **UI 优化**
- 合并状态显示到单行
- 金色图标 + 灰色文字
- 暗绿色"会话已保存"/暗红色"保存异常"
6. **双记忆系统合并**
- 读取 picoclaw 的 MEMORY.md 作为长期记忆
- 合并到 hxclaw 的会话摘要上下文
- AI 同时看到长期记忆和会话摘要
5. **JSON 导出**
- 退出时自动导出
- 手动导出
---
## 项目配置
### project.config.yml
配置文件位于项目根目录:
```yaml
# hxclaw 项目配置文件
# 模拟流式输出配置
streaming:
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
# Markdown 渲染配置
markdown:
theme: dark # 渲染主题dark, light, dracula, tokyo-night 等
line_width: -1 # 自动换行宽度(-1=禁用0=自动,>0=固定宽度)
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "
# TTS 语音配置
tts:
enabled: false # 全局开关(默认关闭)
port: 9876 # mimo-tts daemon 端口
auto: true # AI 回复后自动朗读
```
配置加载优先级:
1. 环境变量 `HXCLAW_CONFIG` 指定路径
2. 项目根目录 `project.config.yml`
---
### 用户配置
用户配置文件位于 `~/.config/hxclaw/config.yml`,启动时自动创建。
```yaml
# hxclaw 用户配置文件
# 此文件位于 ~/.config/hxclaw/config.yml
# 用户配置优先于项目配置
# Markdown 渲染配置
markdown:
theme: dark # 渲染主题
line_width: 0 # 换行宽度
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "
# TTS 语音配置
tts:
enabled: false # 全局开关
auto: true # AI 回复后自动朗读
```
---
## 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 +215,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 +259,4 @@ go build -o hxclaw ./cmd/hxclaw
- 不要修改 picoclaw 源码
- 保持代码独立,便于后续版本同步
- 优先实现核心功能,再考虑增强功能
- 文档和代码同步更新
- 文档和代码同步更新

View File

@@ -2,44 +2,120 @@
## 版本记录
### v0.1.0 (规划中)
### v0.3.0 (2026-04-27)
- **双记忆系统合并**
- 读取 picoclaw 的 MEMORY.md 作为长期记忆
- 合并到 hxclaw 的会话摘要上下文
- AI 同时看到长期记忆和会话摘要
- **独立上下文系统**
- 创建 GetContextPrompt() 返回会话摘要
- 注入到 ProcessDirect() 调用前
- 不再依赖 picoclaw session 管理
- 修复 recall 结果污染 session summary 问题
- **数据库层完善**
- 集成 libSQL (TursoDB)
- 创建 sessions 和 chats 表
- 实现 CRUD 操作
- 数据库保存在 `~/.config/hxclaw/hxclaw.db`
- 向量存储使用 binary 编码float32
- **向量检索功能**
- 硅基流动 BGE-M3 API 集成
- 向量生成和存储
- Cosine Similarity 计算
- SearchSimilar() 函数实现
- 4 个查询场景RecallHistory, RecallTopic, RecallSession, RecallWithinSession
- **三重检测机制**
- 关键词匹配(之前、聊过、记得等)
- 向量相似度自动检测auto_recall + 阈值)
- /recall 命令强制触发
- 配置项keywords, auto_recall, similarity_threshold, max_results
- **MongoDB 风格导出**
- 固定路径:`~/.config/hxclaw/export-data.json`
- chats 嵌套在 sessions 下
- 增量导出,同 session 累加
- 版本控制version 字段)
- **UI 优化**
- 合并状态显示到单行(耗时 · 状态 · 消息数)
- 颜色设计:金色图标 + 灰色文字
- 暗绿色"会话已保存" / 暗红色"会话保存异常"
- **配置项更新**
- memory.recall 配置
- memory.vector.max_search_results
- memory.auto_export替换 export_on_exit
- 默认 max_search_results = 10
---
### 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] 流式输出完成后添加空行分隔
- [x] TTS 语音朗读功能
- [x] 集成 mimo-tts client (TCP 连接)
- [x] 配置文件开关 (tts.enabled)
- [x] 命令行切换 (/tts on/off/status)
- [x] 临时 TTS 前缀 (T 消息)
- [x] 动态提示符显示状态
- [x] 静默失败处理
### v0.2.0 (计划)
### v0.3.0 (当前)
- [ ] Markdown 渲染
- [ ] Markdown 解析
- [ ] 基础样式(粗体、斜体、链接
- [ ] 代码块渲染
- [ ] 表格渲染
- [ ] 列表渲染
- [x] 双记忆系统合并picoclaw MEMORY.md + hxclaw 会话摘要)
- [x] 数据库层集成libSQL
- [x] 独立上下文系统(不再依赖 picoclaw session
- [x] 会话摘要注入
- [x] UI 优化(合并显示、颜色设计)
- [x] 向量检索(硅基流动 API
- [x] 4 个查询场景RecallHistory, RecallTopic...
- [x] 三重检测机制
- [x] MongoDB 风格导出
### v0.3.0 (计划)
---
- [ ] 代码高亮
- [ ] 集成 glow 或类似库
- [ ] 支持常见语言语法高亮
## 待实现功能
### v0.4.0 (计划)
- [ ] 命令行参数支持(--theme, --tts 等)
- [ ] 多语言支持
- [ ] /new 命令开始新会话
- [ ] /memory list|show 命令
---
@@ -54,6 +130,12 @@
- [x] 实现流式输出核心逻辑
- [x] 编译成功,生成 hxclaw 二进制
- [x] 添加 spinner 加载动画组件
- [x] 实现 Markdown 渲染glamour
- [x] 实现项目配置化project.config.yml
- [x] 实现 TTS 语音朗读功能
- [x] 集成 libSQL 数据库
- [x] 实现独立上下文系统
- [x] UI 状态合并显示
---

View File

@@ -0,0 +1,435 @@
// Package internal 包含 hxclaw 的内部工具模块
// 提供配置管理、Markdown 渲染、输入读取等功能
package internal
import (
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// Config 是项目配置结构体包含流式输出、Markdown 渲染和 UI 相关配置
type Config struct {
// Streaming 流式输出配置
Streaming StreamingConfig `yaml:"streaming"`
// Markdown Markdown 渲染配置
Markdown MarkdownConfig `yaml:"markdown"`
// UI UI 显示配置
UI UIConfig `yaml:"ui"`
// TTS TTS 语音配置
TTS TTSConfig `yaml:"tts"`
// Memory 聊天记忆体配置
Memory MemoryConfig `yaml:"memory"`
}
// StreamingConfig 流式输出配置,控制模拟打字效果的延迟时间
type StreamingConfig struct {
// LineDelayMs 每行输出延迟(毫秒)
LineDelayMs int `yaml:"line_delay_ms"`
// LastLineDelayMs 最后一行输出延迟(毫秒)
LastLineDelayMs int `yaml:"last_line_delay_ms"`
}
// MarkdownConfig Markdown 渲染配置,控制渲染主题和换行行为
type MarkdownConfig struct {
// Theme 渲染主题,支持 dark、light、dracula、tokyo-night 等
Theme string `yaml:"theme"`
// LineWidth 自动换行宽度0=自动,>0=固定宽度,-1=禁用)
LineWidth int `yaml:"line_width"`
}
// UIConfig UI 显示配置,控制 Logo 和用户提示符
type UIConfig struct {
// Logo 应用 Logo 字符
Logo string `yaml:"logo"`
// UserIcon 用户输入提示符
UserIcon string `yaml:"user_icon"`
}
// TTSConfig TTS 语音配置
type TTSConfig struct {
// Enabled 全局开关
Enabled bool `yaml:"enabled"`
// Port 端口
Port int `yaml:"port"`
// Auto AI 回复后自动朗读
Auto bool `yaml:"auto"`
}
// MemoryConfig 聊天记忆体配置
type MemoryConfig struct {
// Enabled 启用开关
Enabled bool `yaml:"enabled"`
// DBPath 数据库路径
DBPath string `yaml:"db_path"`
// AutoSession 自动创建 Session
AutoSession bool `yaml:"auto_session"`
// AutoExport 退出时自动导出
AutoExport bool `yaml:"auto_export"`
// Vector 向量服务配置
Vector VectorConfig `yaml:"vector"`
// Recall 检索配置
Recall RecallConfig `yaml:"recall"`
}
// VectorConfig 向量服务配置
type VectorConfig struct {
// APIKey 硅基流动 API Key
APIKey string `yaml:"api_key"`
// BaseURL API 地址
BaseURL string `yaml:"base_url"`
// Model 向量模型
Model string `yaml:"model"`
// Dimension <20><>量维度
Dimension int `yaml:"dimension"`
// MaxSearchResults 最大检索结果数
MaxSearchResults int `yaml:"max_search_results"`
}
// RecallConfig 检索配置
type RecallConfig struct {
// Keywords 触发关键词列表
Keywords []string `yaml:"keywords"`
// AutoRecall 是否自动检测相似度
AutoRecall bool `yaml:"auto_recall"`
// SimilarityThreshold 相似度阈值
SimilarityThreshold float64 `yaml:"similarity_threshold"`
// MaxResults 最大检索结果数
MaxResults int `yaml:"max_results"`
}
var (
// defaultCfg 默认配置值,当配置文件不存在或字段为空时使用
defaultCfg = Config{
Streaming: StreamingConfig{
LineDelayMs: 1000,
LastLineDelayMs: 600,
},
Markdown: MarkdownConfig{
Theme: "dark",
LineWidth: -1,
},
UI: UIConfig{
Logo: "🦐",
UserIcon: "👀 ",
},
TTS: TTSConfig{
Enabled: false,
Port: 9876,
Auto: true,
},
Memory: MemoryConfig{
Enabled: true,
DBPath: "",
AutoSession: true,
AutoExport: true,
Vector: VectorConfig{
APIKey: "",
BaseURL: "https://api.siliconflow.cn/v1",
Model: "BAAI/bge-m3",
Dimension: 1024,
MaxSearchResults: 10,
},
Recall: RecallConfig{
Keywords: []string{"之前", "聊过", "记得", "找找", "曾经", "谈论过", "提过"},
AutoRecall: true,
SimilarityThreshold: 0.7,
MaxResults: 5,
},
},
}
cfg *Config // 已合并的配置(用户配置 + 项目配置)
cfgLock sync.RWMutex // 配置读写锁
)
// 用户配置文件路径常量
const (
userConfigFile = "config.yml" // 用户配置文件名
)
// LoadProjectConfig 加载并合并项目配置
// 加载顺序:用户配置(~/.config/hxclaw/config.yml > 项目配置project.config.yml
// 最终配置由两者合并得出,用户配置优先于项目配置
func LoadProjectConfig() error {
cfgLock.Lock()
defer cfgLock.Unlock()
userCfg := loadUserConfig()
projCfg := loadProjectConfig()
merged := mergeConfig(userCfg, projCfg)
cfg = merged
return nil
}
// loadUserConfig 加载用户配置文件
// 路径:~/.config/hxclaw/config.yml
// 如果文件不存在,则自动创建默认配置文件
func loadUserConfig() *Config {
userPath := getUserConfigPath()
if userPath == "" {
return nil
}
data, err := os.ReadFile(userPath)
if err != nil {
if os.IsNotExist(err) {
createUserConfig(userPath)
return nil
}
return nil
}
var userCfg Config
if err := yaml.Unmarshal(data, &userCfg); err != nil {
return nil
}
return &userCfg
}
// createUserConfig 创建默认的用户配置文件
func createUserConfig(userPath string) error {
dir := filepath.Dir(userPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
defaultContent := `# hxclaw 用户配置文件
# 此文件位于 ~/.config/hxclaw/config.yml
# 用户配置优先于项目配置
# Markdown 渲染配置
markdown:
theme: dark
line_width: 0
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "
# TTS 语音配置
tts:
enabled: false
auto: true
# 聊天记忆体配置
memory:
enabled: true
auto_session: true
auto_export: true
vector:
api_key: ""
base_url: "https://api.siliconflow.cn/v1"
model: "BAAI/bge-m3"
dimension: 1024
max_search_results: 10
recall:
keywords: ["之前", "聊过", "记得", "找找", "曾经", "谈论过", "提过"]
auto_recall: true
similarity_threshold: 0.7
max_results: 5
`
return os.WriteFile(userPath, []byte(defaultContent), 0644)
}
// loadProjectConfig 加载项目级配置文件
// 路径优先级:环境变量 HXCLAW_CONFIG 指定路径 > ./project.config.yml
func loadProjectConfig() *Config {
cfgPath := GetProjectConfigPath()
if cfgPath == "" {
return &defaultCfg
}
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
return &defaultCfg
}
return &defaultCfg
}
var projCfg Config
if err := yaml.Unmarshal(data, &projCfg); err != nil {
return &defaultCfg
}
return &projCfg
}
// mergeConfig 合并用户配置和项目配置
// 合并规则:用户配置优先于项目配置,项目配置优先于默认配置
func mergeConfig(userCfg, projCfg *Config) *Config {
result := defaultCfg
// 先应用项目配置
if projCfg != nil {
if projCfg.Streaming.LineDelayMs > 0 {
result.Streaming.LineDelayMs = projCfg.Streaming.LineDelayMs
}
if projCfg.Streaming.LastLineDelayMs > 0 {
result.Streaming.LastLineDelayMs = projCfg.Streaming.LastLineDelayMs
}
if projCfg.Markdown.Theme != "" {
result.Markdown.Theme = projCfg.Markdown.Theme
}
if projCfg.Markdown.LineWidth != 0 {
result.Markdown.LineWidth = projCfg.Markdown.LineWidth
}
if projCfg.UI.Logo != "" {
result.UI.Logo = projCfg.UI.Logo
}
if projCfg.UI.UserIcon != "" {
result.UI.UserIcon = projCfg.UI.UserIcon
}
if projCfg.TTS.Port > 0 {
result.TTS.Port = projCfg.TTS.Port
}
// Memory 配置
if projCfg.Memory.Enabled {
result.Memory.Enabled = projCfg.Memory.Enabled
}
if projCfg.Memory.DBPath != "" {
result.Memory.DBPath = projCfg.Memory.DBPath
}
if projCfg.Memory.AutoSession {
result.Memory.AutoSession = projCfg.Memory.AutoSession
}
if projCfg.Memory.AutoExport {
result.Memory.AutoExport = projCfg.Memory.AutoExport
}
if projCfg.Memory.Vector.APIKey != "" {
result.Memory.Vector.APIKey = projCfg.Memory.Vector.APIKey
}
if projCfg.Memory.Vector.BaseURL != "" {
result.Memory.Vector.BaseURL = projCfg.Memory.Vector.BaseURL
}
if projCfg.Memory.Vector.Model != "" {
result.Memory.Vector.Model = projCfg.Memory.Vector.Model
}
if projCfg.Memory.Vector.Dimension > 0 {
result.Memory.Vector.Dimension = projCfg.Memory.Vector.Dimension
}
if projCfg.Memory.Vector.MaxSearchResults > 0 {
result.Memory.Vector.MaxSearchResults = projCfg.Memory.Vector.MaxSearchResults
}
// Recall 配置
if len(projCfg.Memory.Recall.Keywords) > 0 {
result.Memory.Recall.Keywords = projCfg.Memory.Recall.Keywords
}
if projCfg.Memory.Recall.AutoRecall {
result.Memory.Recall.AutoRecall = projCfg.Memory.Recall.AutoRecall
}
if projCfg.Memory.Recall.SimilarityThreshold > 0 {
result.Memory.Recall.SimilarityThreshold = projCfg.Memory.Recall.SimilarityThreshold
}
if projCfg.Memory.Recall.MaxResults > 0 {
result.Memory.Recall.MaxResults = projCfg.Memory.Recall.MaxResults
}
}
// 再应用用户配置(覆盖项目配置)
if userCfg != nil {
// Markdown
if userCfg.Markdown.Theme != "" {
result.Markdown.Theme = userCfg.Markdown.Theme
}
if userCfg.Markdown.LineWidth != 0 {
result.Markdown.LineWidth = userCfg.Markdown.LineWidth
}
// UI
if userCfg.UI.Logo != "" {
result.UI.Logo = userCfg.UI.Logo
}
if userCfg.UI.UserIcon != "" {
result.UI.UserIcon = userCfg.UI.UserIcon
}
// TTS用户配置可以覆盖 enabled 和 auto
if userCfg.TTS.Enabled {
result.TTS.Enabled = userCfg.TTS.Enabled
}
if userCfg.TTS.Port > 0 {
result.TTS.Port = userCfg.TTS.Port
}
if userCfg.TTS.Auto {
result.TTS.Auto = userCfg.TTS.Auto
}
// Memory 配置(用户配置优先)
if userCfg.Memory.Enabled {
result.Memory.Enabled = userCfg.Memory.Enabled
}
if userCfg.Memory.DBPath != "" {
result.Memory.DBPath = userCfg.Memory.DBPath
}
if userCfg.Memory.AutoSession {
result.Memory.AutoSession = userCfg.Memory.AutoSession
}
if userCfg.Memory.AutoExport {
result.Memory.AutoExport = userCfg.Memory.AutoExport
}
// 向量 API Key 只能在用户配置中指定
if userCfg.Memory.Vector.APIKey != "" {
result.Memory.Vector.APIKey = userCfg.Memory.Vector.APIKey
}
if userCfg.Memory.Vector.BaseURL != "" {
result.Memory.Vector.BaseURL = userCfg.Memory.Vector.BaseURL
}
if userCfg.Memory.Vector.Model != "" {
result.Memory.Vector.Model = userCfg.Memory.Vector.Model
}
if userCfg.Memory.Vector.Dimension > 0 {
result.Memory.Vector.Dimension = userCfg.Memory.Vector.Dimension
}
if userCfg.Memory.Vector.MaxSearchResults > 0 {
result.Memory.Vector.MaxSearchResults = userCfg.Memory.Vector.MaxSearchResults
}
// Recall 配置
if len(userCfg.Memory.Recall.Keywords) > 0 {
result.Memory.Recall.Keywords = userCfg.Memory.Recall.Keywords
}
if userCfg.Memory.Recall.AutoRecall {
result.Memory.Recall.AutoRecall = userCfg.Memory.Recall.AutoRecall
}
if userCfg.Memory.Recall.SimilarityThreshold > 0 {
result.Memory.Recall.SimilarityThreshold = userCfg.Memory.Recall.SimilarityThreshold
}
if userCfg.Memory.Recall.MaxResults > 0 {
result.Memory.Recall.MaxResults = userCfg.Memory.Recall.MaxResults
}
}
return &result
}
// GetProjectConfig 获取当前生效的配置(线程安全)
// 如果配置未加载,返回默认配置
func GetProjectConfig() *Config {
cfgLock.RLock()
defer cfgLock.RUnlock()
if cfg == nil {
return &defaultCfg
}
return cfg
}
// getUserConfigPath 获取用户配置文件路径
// 路径格式:~/.config/hxclaw/config.yml
func getUserConfigPath() string {
return GetConfigFile()
}
// GetUserConfigDir 获取用户配置目录
func GetUserConfigDir() string {
return GetConfigDir()
}
func GetProjectConfigPath() 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,42 @@ 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.Theme != "" {
return cfg.Markdown.Theme
}
}
return style
if s := os.Getenv("GLAMOUR_STYLE"); s != "" {
return s
}
return "dark"
}
func getWrapWidth() int {
if cfg := GetProjectConfig(); cfg != nil {
if cfg.Markdown.LineWidth > 0 {
return cfg.Markdown.LineWidth
}
if cfg.Markdown.LineWidth < 0 {
return 0
}
}
if cols := os.Getenv("COLUMNS"); cols != "" {
if w, err := strconv.Atoi(cols); err == nil && w > 0 {
return w
}
}
if cols := os.Getenv("LINES"); cols != "" {
if w, err := strconv.Atoi(cols); err == nil && w > 0 {
return w
}
}
width, _, err := term.GetSize(0)
if err != nil || width <= 0 {
return 0
}
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

@@ -11,6 +11,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/hxclaw/hxclaw/cmd/hxclaw/internal"
"github.com/hxclaw/hxclaw/cmd/hxclaw/internal/memory"
"github.com/muesli/termenv"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -18,12 +19,18 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
var totalCompletionTokens int
const Logo = "🦐"
var currentSession *memory.Session
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 {
@@ -57,12 +64,33 @@ func main() {
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
memoryCfg := internal.GetProjectConfig().Memory
if memoryCfg.Enabled {
// 优先使用用户配置中的 db_path如果没有则使用默认路径
dbPath := memoryCfg.DBPath
if dbPath == "" {
dbPath = memory.GetDefaultDBPath()
}
fmt.Printf("初始化记忆体db_path: %s\n", dbPath)
if err := memory.Init(memory.WithDBPath(dbPath)); err != nil {
fmt.Fprintf(os.Stderr, "警告:初始化记忆体失败: %v将使用无记忆模式\n", err)
} else {
fmt.Println("记忆体初始化成功")
memory.InitVector(
memory.WithAPIKey(memoryCfg.Vector.APIKey),
memory.WithBaseURL(memoryCfg.Vector.BaseURL),
memory.WithModel(memoryCfg.Vector.Model),
)
}
}
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", Logo)
interactiveMode(agentLoop, "cli:default")
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", Logo)
basePrompt := internal.GetProjectConfig().UI.UserIcon
prompt := internal.GetTTSPrompt(basePrompt)
rl, err := internal.NewReadline(prompt)
if err != nil {
@@ -73,11 +101,17 @@ 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 {
if err == internal.ErrInterrupt || err == internal.ErrEOF {
fmt.Println("\n再见!")
memory.ExportIfNeeded()
return
}
fmt.Printf("读取输入错误: %v\n", err)
@@ -91,21 +125,61 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
if input == "exit" || input == "quit" {
fmt.Println("再见!")
memory.ExportIfNeeded()
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 strings.HasPrefix(input, "/new") {
handleNewSessionCommand(rl, basePrompt)
continue
}
if strings.HasPrefix(input, "/memory") {
handleMemoryCommand(input)
continue
}
if strings.HasPrefix(input, "/sessions") {
handleSessionsCommand()
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.UserIcon))
line, err := reader.ReadString()
if err != nil {
if err == internal.ErrEOF {
fmt.Println("\n再见!")
memory.ExportIfNeeded()
return
}
fmt.Printf("读取输入错误: %v\n", err)
@@ -119,139 +193,163 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
if input == "exit" || input == "quit" {
fmt.Println("再见!")
memory.ExportIfNeeded()
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 strings.HasPrefix(input, "/new") {
handleNewSessionCommand(nil, internal.GetProjectConfig().UI.UserIcon)
continue
}
if strings.HasPrefix(input, "/memory") {
handleMemoryCommand(input)
continue
}
if strings.HasPrefix(input, "/sessions") {
handleSessionsCommand()
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 实例")
// 保存原始输入用于后续保存
originalInput := input
// 注入 hxclaw 的上下文摘要
memoryCfg := internal.GetProjectConfig().Memory
if memoryCfg.Enabled {
contextPrompt := memory.GetContextPrompt(input)
if contextPrompt != "" {
input = contextPrompt + "\n用户新问题: " + input
}
}
spinner := internal.NewSpinner("思考中...")
spinner.Start()
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
spinner.Stop()
if err != nil {
fmt.Printf("警告: %v\n", err)
}
if resp == "" {
fmt.Println("(空响应,跳过保存)")
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
)
// 保存聊天记录到数据库
var chatCount int
var saveErr error
memoryCfg = internal.GetProjectConfig().Memory
if memoryCfg.Enabled {
chatCount, saveErr = memory.SaveChat(originalInput, resp, !memory.ShouldSkipSummaryUpdate(originalInput))
}
// 获取工具定义
toolDefs := agentInstance.Tools.ToProviderDefs()
elapsed := time.Since(startTime)
printElapsed(elapsed, chatCount, saveErr)
}
// 启动 spinner显示 "思考中..."
spinner := internal.NewSpinner("思考中...")
spinner.Start()
func clearSpinnerLine() {
output := termenv.DefaultOutput()
output.ClearLine()
fmt.Print("\r")
os.Stdout.Sync()
}
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)
}
})
func outputLineByLine(text string) {
if text == "" {
return
}
if err != nil {
spinner.Stop()
fmt.Printf("流式调用错误: %v\n", err)
return
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 == "" {
lipgloss.Print("\n")
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()
}
lipgloss.Print(line + "\n")
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)
}
}
lipgloss.Print("\n")
}
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"))
memoryOkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4a9e6b")) // 暗绿色
memoryErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#c75050")) // 暗红色
)
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, chatCount int, saveErr error) {
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))
fmt.Printf(" %s%s\n\n", icon, text)
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))
if statusText != "" {
fmt.Printf(" %s%s · %s · %s\n\n", icon, timeText, statusText, memCountText)
} else {
fmt.Printf(" %s%s · %s\n\n", icon, timeText, memCountText)
}
}
func formatTokens(n int) string {
@@ -267,3 +365,151 @@ 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]")
}
}
func handleNewSessionCommand(rl *internal.Readline, basePrompt string) {
uuid, err := memory.CreateNewSession()
if err != nil {
fmt.Printf("创建新会话失败: %v\n", err)
return
}
fmt.Printf("已创建新会话: %s\n", uuid)
currentSession = nil
}
func handleMemoryCommand(input string) {
args := strings.Fields(input)
if len(args) == 1 || args[1] == "list" {
sessions, err := memory.ListSessions()
if err != nil {
fmt.Printf("查询会话失败: %v\n", err)
return
}
if len(sessions) == 0 {
fmt.Println("暂无会话记录")
return
}
fmt.Printf("共有 %d 个会话记录:\n", len(sessions))
for _, s := range sessions {
summary := s.Summary
if summary == "" {
summary = "(无摘要)"
} else if len(summary) > 50 {
summary = summary[:50] + "..."
}
fmt.Printf(" - %s: %s\n", s.UUID[:8], summary)
}
return
}
switch args[1] {
case "show":
session := memory.GetCurrentSession()
if session == nil {
latest, err := memory.GetLatestSession()
if err != nil || latest == nil {
fmt.Println("暂无会话记录")
return
}
session = latest
}
if session.Summary == "" {
fmt.Println("当前会话暂无摘要")
return
}
fmt.Printf("=== 会话摘要 (%s) ===\n%s\n", session.UUID[:8], session.Summary)
case "current":
if currentSession == nil {
fmt.Println("当前无活跃会话")
return
}
fmt.Printf("当前会话: %s\n", currentSession.UUID)
fmt.Printf("聊天记录数: %d\n", len(currentSession.ChatIDs))
default:
fmt.Println("用法: /memory [list|show|current]")
}
}
func handleSessionsCommand() {
sessions, err := memory.ListSessions()
if err != nil {
fmt.Printf("查询会话失败: %v\n", err)
return
}
if len(sessions) == 0 {
fmt.Println("暂无会话记录")
return
}
fmt.Printf("共有 %d 个会话记录:\n", len(sessions))
for _, s := range sessions {
summary := s.Summary
if summary == "" {
summary = "(无摘要)"
} else if len(summary) > 30 {
summary = summary[:30] + "..."
}
fmt.Printf(" %s | %d 条消息 | %s\n", s.UUID[:8], len(s.ChatIDs), summary)
}
}

461
docs/architecture.md Normal file
View File

@@ -0,0 +1,461 @@
# hxclaw 记忆体系统架构图
## 一、数据流向总图
```mermaid
flowchart TB
subgraph 用户输入
A[用户输入]
A1[普通对话]
A2[查询历史]
A3[/recall 命令]
end
subgraph 上下文注入
B[GetContextPrompt]
B0[读取 picoclaw MEMORY.md]
B1[获取当前 Session 摘要]
B2[检测查询意图]
B3[按需调用 Recall]
end
subgraph AI 处理
C[ProcessDirect]
C1[工具调用]
C2[多轮对话]
end
subgraph 保存流程
D[SaveChat]
D1[INSERT chat]
D2[UPDATE chat 摘要+向量]
D3[UPDATE session 摘要+向量]
end
subgraph 数据库
E[sessions 表]
F[chats 表]
end
subgraph 向量服务
G[硅基流动 API]
end
subgraph 外部记忆
H[picoclaw MEMORY.md]
end
A --> B
B0 --> B
H --> B0
B --> C
C --> D
D --> F
D --> E
F --> G
E --> G
A1 -->|普通对话| B1
A2 -->|检测关键词| B2
A3 -->|强制触发| B3
```
---
## 二、对话流程(默认模式)
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.go
participant CP as GetContextPrompt
participant PicoMem as picoclaw MEMORY.md
participant AI as ProcessDirect
participant SC as SaveChat
participant DB as libSQL
participant VS as 向量服务
U->>M: 用户输入
M->>CP: GetContextPrompt(userInput)
CP->>PicoMem: 读取长期记忆
PicoMem-->>CP: 长期记忆内容
CP->>DB: 获取 currentSession
DB->>CP: session.Summary
Note over CP: 合并:长期记忆 + 会话摘要
CP->>M: 返回上下文
M->>AI: ProcessDirect(context + input)
AI->>U: 返回 AI 回复
U->>SC: SaveChat(userInput, aiReply)
SC->>DB: INSERT chat
SC->>DB: UPDATE session
Note over SC: 更新摘要和向量
SC->>VS: 异步生成向量
VS-->>SC: embedding
SC->>DB: 保存到 chats.summary_embedding
SC->>DB: 保存到 sessions.summary_embedding
```
---
## 三、四种查询场景
```mermaid
flowchart LR
subgraph 查询场景
Q1[场景1: 历史摘要]
Q2[场景2: 话题检索]
Q3[场景3: 会话详情]
Q4[场景4: 会话内检索]
end
subgraph 触发条件
T1["之前聊过什么?"]
T2["谈论过 xxx?"]
T3["那次还说过什么?"]
T4["xxx 呢?"]
end
subgraph 查询逻辑
L1[查 sessions 表]
L2[chats 向量检索<br/>Group By session_id]
L3[查 sessions 表<br/>WHERE id = ?]
L4[chats 向量检索<br/>WHERE session_id = ?]
end
subgraph 返回
R1[所有会话摘要]
R2[按 session 分组<br/>top5 摘要拼接]
R3[指定 session 摘要]
R4[同 session 内相关摘要]
end
T1 --> Q1
T2 --> Q2
T3 --> Q3
T4 --> Q4
Q1 --> L1
Q2 --> L2
Q3 --> L3
Q4 --> L4
L1 --> R1
L2 --> R2
L3 --> R3
L4 --> R4
```
---
## 四、三重检测机制
```mermaid
flowchart TB
I[用户输入]
subgraph 检测层
D1[/recall 命令]
D2[关键词匹配]
D3[向量相似度]
end
subgraph 配置
C1[keywords]
C2[auto_recall]
C3[similarity_threshold]
end
I --> D1
I --> D2
I --> D3
D2 --> C1
D3 --> C3
C1 -->|匹配成功| R[强制 Recall]
D3 --> C2
C2 -->|开启| C3
C3 -->|阈值判断| R
D1 -->|触发| R
```
---
## 五、数据库表结构
```mermaid
erDiagram
sessions {
int id PK
string uuid
text summary
blob summary_embedding
string chat_ids
int created_at
int updated_at
}
chats {
int id PK
int session_id FK
text user_input
text ai_replies
text summary
blob summary_embedding
int created_at
int updated_at
}
sessions ||--o{ chats : "has many"
```
---
## 六、上下文演变
```mermaid
flowchart LR
subgraph 时间线
T1[开始]
T2[第1次对话]
T3[第2次对话]
T4[第N次对话]
T5[第1000次对话]
end
subgraph 上下文状态
S1[空]
S2[摘要1]
S3[摘要1+摘要2]
SN[摘要N]
S1000[摘要1000]
end
subgraph 实际状态
A1["context = 空 + 长期记忆"]
A2["context = 摘要1 + 长期记忆"]
A3["context = 摘要2 + 长期记忆"]
AN["context = 摘要N + 长期记忆"]
A1000["context = 摘要1000 + 长期记忆"]
end
T1 --> S1 --> A1
T2 --> S2 --> A2
T3 --> S3 -->|覆盖| A3
T4 --> SN -->|覆盖| AN
T5 --> S1000 -->|覆盖| A1000
Note over A1000: 始终只有1条会话摘要 + 长期记忆
```
**注意**:长期记忆来自 `~/.picoclaw/workspace/memory/MEMORY.md`,不受会话影响,会持续保留。
---
## 七、完整流程图
```mermaid
flowchart TB
subgraph 输入层
INPUT[用户输入]
CMD[/recall]
KW[关键词]
end
subgraph 检测层
CHECK{检测查询意图}
RECALL{Recall 触发?}
end
subgraph 上下文构建
CONTEXT[上下文]
SESSION_SUM[当前 Session 摘要]
RECALL_RES[Recall 结果]
end
subgraph AI 层
AI[ProcessDirect]
RESP[AI 回复]
end
subgraph 保存层
SAVE[SaveChat]
INSERT_CHAT[INSERT chat]
UPDATE_CHAT[UPDATE chat]
UPDATE_SESSION[UPDATE session]
end
subgraph 数据库
DB[(libSQL)]
SESSIONS[sessions 表]
CHATS[chats 表]
end
subgraph 向量服务
VS[向量服务]
EMB[Generate Embedding]
end
INPUT --> CHECK
CMD --> CHECK
KW --> CHECK
CHECK -->|普通对话| RECALL
CHECK -->|是查询| RECALL_RES
RECALL -->|否| SESSION_SUM
RECALL_RES --> CONTEXT
SESSION_SUM --> CONTEXT
CONTEXT --> AI
INPUT --> AI
AI --> RESP
RESP --> SAVE
SAVE --> INSERT_CHAT
INSERT_CHAT --> DB
DB --> CHATS
SAVE --> UPDATE_CHAT
UPDATE_CHAT --> DB
SAVE --> UPDATE_SESSION
UPDATE_SESSION --> DB
CHATS --> EMB
SESSIONS --> EMB
EMB --> VS
VS --> CHATS
VS --> SESSIONS
```
---
## 八、关键文件对应关系
| 模块 | 文件 | 职责 |
|------|------|------|
| 主流程 | `main.go` | 调用 GetContextPrompt、SaveChat |
| 配置 | `internal/config.go` | RecallConfig、VectorConfig |
| 数据库 | `internal/memory/db.go` | CRUD 操作 |
| 模型 | `internal/memory/model.go` | Session、Chat 结构体 |
| 向量 | `internal/memory/vector.go` | 硅基流动 API 调用 |
| 保存 | `internal/memory/save.go` | SaveChat、三重检测、长期记忆读取 |
| 查询 | `internal/memory/skill.go` | 4 个 Recall 函数 |
| 导出 | `internal/memory/export.go` | JSON 导出 |
---
## 九、双记忆系统合并
### 记忆来源
| 类型 | 来源 | 持久性 |
|------|------|--------|
| **长期记忆** | `~/.picoclaw/workspace/memory/MEMORY.md` | 跨会话AI 自动更新 |
| **会话摘要** | `~/.config/hxclaw/hxclaw.db` sessions 表 | 当前会话,动态更新 |
| **聊天详情** | `~/.config/hxclaw/hxclaw.db` chats 表 | 所有历史,支持向量检索 |
### 上下文注入格式
```markdown
=== 长期记忆 ===
picoclaw MEMORY.md 内容)
=== 当前会话摘要 ===
hxclaw sessions 表中的摘要)
用户新问题: xxx
```
### 实现原理
`GetContextPrompt()` 函数在构建上下文时:
1. 读取 picoclaw 的 `MEMORY.md` 文件
2. 解析并提取有效内容(跳过 Markdown 标题)
3. 合并到上下文顶部
4. 追加会话摘要
5. 如有 recall 结果,继续追加
### 数据流向
```
picoclaw MEMORY.md ──┐
├──→ GetContextPrompt ──→ AI 上下文
hxclaw sessions ────┘
```
---
## 十、配置项说明
```yaml
memory:
recall:
keywords: ["之前", "聊过", "记得", "找找", "曾经", "谈论过", "提过"]
auto_recall: true # 自动相似度检测
similarity_threshold: 0.7 # 相似度阈值
max_results: 5 # 最大检索结果
vector:
max_search_results: 10 # 向量检索最大结果
```
---
## 十一、MongoDB 风格导出
### 导出文件
- 路径:`~/.config/hxclaw/export-data.json`
- 格式:每次退出时自动增量导出
### JSON 结构
```json
{
"version": 1,
"exported_at": "2026-04-27T06:12:22+08:00",
"sessions": [
{
"id": 1,
"uuid": "session-uuid",
"summary": "会话摘要...",
"chat_ids": [1, 2, 3],
"created_at": 1740000000,
"updated_at": 1740000000,
"chats": [
{
"id": 1,
"session_id": 1,
"user_input": "用户输入",
"ai_replies": ["AI回复1", "AI回复2"],
"summary": "摘要",
"created_at": 1740000000,
"updated_at": 1740000000
}
]
}
]
}
```
### 设计特点
| 特性 | 说明 |
|------|------|
| 嵌套结构 | chats 嵌套在 sessions 下,类似 MongoDB 文档 |
| 增量导出 | 同 session 的 chats 累加,不重复创建 |
| UUID 匹配 | 按 UUID 判断是新建还是更新 |
| 版本控制 | version 字段支持格式演进 |
```

260
docs/memory-plan.md Normal file
View File

@@ -0,0 +1,260 @@
# hxclaw 聊天记忆体实现计划
## 概述
hxclaw 聊天记忆体是一个基于 libSQL (TursoDB) 的对话上下文管理系统,用于替代 picoclaw 原有的 memory.md 文件,实现更灵活、可控的会话历史管理。
**注意**:本计划文档已实现完成,详细功能请参考 `docs/discussion.md`
## 技术选型
| 组件 | 选型 | 说明 |
|------|------|------|
| 数据库 | libSQL (嵌入式) | 本地文件数据库 `~/.config/hxclaw/hxclaw.db` |
| 向量模型 | BAAI/bge-m3 | 1024 维,硅基流动 API |
| UUID | github.com/google/uuid | 生成唯一会话 ID |
| 第三方库 | go_modules/ | 本地化方案解决网络问题 |
## 数据库设计
### sessions 表
```sql
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
summary TEXT,
summary_embedding BLOB,
chat_ids TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
### chats 表
```sql
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
user_input TEXT NOT NULL,
ai_replies TEXT,
summary TEXT,
summary_embedding BLOB,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
```
## 核心流程
1. **Session 管理**:自动创建(首次输入)+ 手动创建(`/new`
2. **消息保存**:在 AI 返回后调用 `SaveChat()` 保存到数据库
3. **查询命令**`/memory`, `/sessions`, `/new`
## 配置
- **启用**:默认启用
- **数据库路径**`~/.config/hxclaw/hxclaw.db`
- **用户配置**`~/.config/hxclaw/config.yml`
## 实现状态
| 功能 | 状态 |
|------|------|
| 数据库 CRUD | ✅ 完成 |
| 聊天记录保存 | ✅ 完成 |
| 命令支持 | ✅ 完成 |
| JSON 导出 | ✅ 完成 |
| 向量服务 | ⚠️ 框架完成,待完善 |
| 向量查询 | ⏳ 待实现 |
hxclaw 聊天记忆体是一个基于 TursoDB (libSQL) 的对话上下文管理系统,用于替代 picoclaw 原有的 memory.md 文件,实现更灵活、可控的会话历史管理。
核心目标:
1. 保留 picoclaw 的 memory.md 作为 AI 长期记忆
2. 新增对话记忆体作为会话维度的短期记忆
3. 支持向量检索和多场景查询
## 技术选型
| 组件 | 选型 | 说明 |
|------|------|------|
| 数据库 | libSQL (嵌入式) | 本地文件数据库,支持 JSON 导出 |
| 向量模型 | BAAI/bge-m3 | 1024 维,硅基流动 API |
| UUID | github.com/google/uuid | 生成唯一会话 ID |
| libSQL 驱动 | github.com/tursodatabase/go-libsql | CGO 需启用 |
## 数据库设计
### 表结构
```sql
-- sessions 表(会话维度)
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL, -- 外键引用用 UUID
summary TEXT, -- 会话压缩后的摘要
summary_embedding BLOB, -- 1024维向量二进制
chat_ids TEXT, -- JSON数组 ["id1","id2",...]
created_at INTEGER NOT NULL, -- Unix时间戳
updated_at INTEGER NOT NULL
);
-- chats 表(消息维度)
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL, -- 外键到 sessions.id
user_input TEXT NOT NULL, -- 用户输入
ai_replies TEXT, -- JSON数组 [{"role":"assistant","content":"..."},...]
summary TEXT, -- 单条消息摘要
summary_embedding BLOB, -- 1024维向量
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_chats_session_id ON chats(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(uuid);
```
### 数据模型
```go
// Session 会话记录
type Session struct {
ID int64 `json:"id"`
UUID string `json:"uuid"`
Summary string `json:"summary"`
SummaryEmbedding []byte `json:"-"`
ChatIDs []int64 `json:"chat_ids"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// Chat 聊天记录
type Chat struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
UserInput string `json:"user_input"`
AIReplies []string `json:"ai_replies"`
Summary string `json:"summary"`
SummaryEmbedding []byte `json:"-"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
```
## 核心流程
### 1. Session 管理
#### 自动开始(模式 A
- 用户首次输入内容时,检查是否存在活跃 Session
- 若无,则自动创建新 Session
#### 手动开始(模式 B
- 用户输入 `/new` 命令,强制创建新 Session
### 2. 消息处理流程
```
用户输入
[可选] 创建新 Session首次输入时
chats 表插入记录 (session_id, user_input, created_at)
AI 处理
AI 返回后:
1. 生成摘要 + 向量
2. chats 表更新 (ai_replies, summary, summary_embedding)
3. sessions 表更新 (chat_ids 追加, summary 压缩, summary_embedding 更新)
将 Session 摘要作为上下文注入 AI
```
### 3. 上下文注入
```
=== 当前会话摘要 ===
[最新摘要内容]
===
```
### 4. 查询场景4种
| 场景 | 用户意图 | 查询方式 |
|------|----------|----------|
| 1 | "之前聊过什么" | 查询最新 Session 的 summary |
| 2 | "某某某相关内容" | 向量搜索 top 5 |
| 3 | "某次谈论时的详情" | 定位 Session ID返回 summary |
| 4 | 同 Session 内其他相关内容 | session_id 过滤 + 向量搜索 |
## 模块设计
### 目录结构
```
cmd/hxclaw/internal/memory/
├── db.go # 数据库初始化和 CRUD 操作
├── model.go # 数据模型定义
├── vector.go # 向量服务(硅基流动 API
├── skill.go # 内置 Skill查询逻辑
└── export.go # 导出功能
```
## 配置项
`project.config.yml` 中新增:
```yaml
# 聊天记忆体配置
memory:
enabled: true # 启用开关
db_path: "./hxclaw.db" # 数据库路径
auto_session: true # 自动创建 Session
export_on_exit: true # 退出时导出
# 向量服务配置
vector:
api_key: "sk-xxx" # 硅基流动 API Key
base_url: "https://api.siliconflow.cn/v1"
model: "BAAI/bge-m3" # 向量模型
dimensions: 1024 # 向量维度
```
## 实现步骤
### 阶段一:基础设施
1. 新增依赖并测试连接go-libsql, google/uuid
2. 创建 `memory/model.go` - 数据模型
3. 创建 `memory/db.go` - CRUD 操作
### 阶段二:向量服务
4. 创建 `memory/vector.go` - 硅基流动 Embedding API
### 阶段三:核心逻辑
5. 修改 `main.go` - 集成 Session
6. 修改消息处理流程 - 存储和摘要
7. 创建 `memory/skill.go` - 查询逻辑
### 阶段四UI 和导出
8. 添加命令支持(`/memory`, `/sessions`
9. 创建 `memory/export.go` - JSON 导出
## 依赖
```bash
go get github.com/tursodatabase/go-libsql
go get github.com/google/uuid
go get github.com/sjzsdu/langchaingo-cn/llms/siliconflow
```
## 注意事项
1. **CGO 要求**`go-libsql` 需要 CGO 启用
2. **向量维度**BGE-M3 默认 1024 维
3. **摘要生成**:使用 AI 返回内容的前 N 字符作为摘要
4. **错误处理**:向量服务失败时降级为纯文本搜索

238
docs/picoclaw-research.md Normal file
View File

@@ -0,0 +1,238 @@
# picoclaw 技术研究报告
## 概述
本报告基于对 picoclaw v0.2.6 源代码的研究,详细分析其核心机制。
---
## 1. 系统架构
### 1.1 核心模块
```
picoclaw/
├── pkg/
│ ├── agent/ # AI 代理核心逻辑
│ ├── providers # LLM 提供者
│ ├── config/ # 配置管理
│ ├── tools/ # 工具注册与执行
│ ├── skills/ # Skill 加载器
│ ├── bus/ # 消息总线
│ └── channels/ # 消息通道
└── web/ # Web 界面
```
### 1.2 依赖关系
- **agent**:核心模块,依赖 providers、tools、skills、bus
- **providers**LLM API 调用OpenAI、Anthropic 等)
- **tools**工具执行exec、read_file、web_fetch 等)
- **skills**:通过 SKILL.md 加载技能定义
---
## 2. Skill 机制
### 2.1 加载机制
**位置**`pkg/skills/loader.go`
Skill 存储在三个位置,优先级:
1. `workspace/skills/` - 项目级
2. `~/.picoclaw/skills/` - 全局
3. 内置 skills 目录
每个 skill 必须是 `SKILL.md` 文件,包含:
- Frontmatter (YAML/JSON) 定义 `name``description`
- Markdown 主体内容(操作指导)
### 2.2 执行机制
**关键发现**Skill 本质是 Markdown 文档,不是可执行代码
AI 阅读 SKILL.md 后,调用实际工具执行任务:
```
加载 SKILL.md → AI 阅读 → 调用工具 → 执行结果
```
---
## 3. 工具执行机制
### 3.1 执行流程
**位置**`pkg/agent/loop.go:runTurn` (约 2332-2899 行)
```go
// 串行执行每个工具调用
for i, tc := range normalizedToolCalls {
toolName := tc.Name
toolArgs := cloneStringAnyMap(tc.Arguments)
// 执行工具
toolResult := ts.agent.Tools.ExecuteWithContext(
execCtx, toolName, toolArgs, ts.channel, ts.chatID, asyncCallback,
)
// 结果添加到消息历史
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: toolCallID,
}
messages = append(messages, toolResultMsg)
}
```
### 3.2 关键特性
| 特性 | 说明 |
|------|------|
| 执行方式 | **串行执行**,非并行 |
| 结果收集 | 通过 `providers.Message` 添加到上下文 |
| 用户显示 | `ForUser` 字段非空时发送给用户 |
| 静默结果 | `SilentResult()` 不显示给用户,但发给 LLM |
### 3.3 工具结果标志
```go
// Silent: 不发送消息给用户,但发送给 LLM
Silent bool `json:"silent"`
// ResponseHandled: 工具已处理响应,不生成独立消息
ResponseHandled bool `json:"response_handled,omitempty"`
// IsError: 标记为错误结果
IsError bool `json:"is_error,omitempty"`
```
---
## 4. 消息处理
### 4.1 消息总线
**位置**`pkg/bus/bus.go`
```
用户输入 → AgentLoop.ProcessDirect() → AI 处理 → 工具执行 ←
bus.PublishOutbound() → 输出
```
### 4.2 消息类型
| 类型 | 说明 |
|------|------|
| InboundMessage | 用户输入 |
| OutboundMessage | AI 输出 |
| ToolResult | 工具结果 |
---
## 5. 异步执行
### 5.1 AsyncExecutor 接口
**位置**`pkg/tools/base.go:107`
```go
type AsyncExecutor interface {
Tool
ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
}
```
### 5.2 使用方式
```go
// 执行时传入回调函数
toolResult := ts.agent.Tools.ExecuteWithContext(
execCtx, toolName, toolArgs, ts.channel, ts.chatID,
asyncCallback, // 异步回调
)
```
---
## 6. TTS 工具分析
### 6.1 文件位置
- 主文件:`pkg/tools/tts_send.go`
- TTS 提供者:`pkg/audio/tts/tts.go`
### 6.2 实现方式
1. 通过 `SendTTSTool` 执行 TTS 合成
2. 调用 `tts.SynthesizeAndStore()` 生成音频
3. 返回文件路径或直接播放
### 6.3 已知问题
| 问题 | 原因 |
|------|------|
| 播放内容不一致 | 异步回调竞争 |
| 结果不显示 | 使用了 `SilentResult()` |
| 多条记录只显示一条 | 串行执行中的状态竞争 |
---
## 7. 配置系统
### 7.1 配置文件
- 项目级:`config.yaml`
- 用户级:`~/.picoclaw/config.json`
- 环境变量:`PICOCLAW_*` 前缀
### 7.2 配置结构
```go
type Config struct {
Agents AgentsConfig `json:"agents"`
Tools ToolsConfig `json:"tools"`
Channels ChannelsConfig `json:"channels"`
Voice VoiceConfig `json:"voice"`
}
```
---
## 8. hxclaw 集成方式
### 8.1 复用策略
hxclaw 通过 Go replace 机制复用 picoclaw
```go
// go.mod
replace github.com/sipeed/picoclaw => ./path/to/picoclaw
```
### 8.2 核心依赖
- `pkg/agent` - AI 代理核心
- `pkg/providers` - LLM 提供者
- `pkg/config` - 配置加载
- `pkg/bus` - 消息总线
---
## 9. 总结
| 模块 | 机制 | 阻塞 |
|------|------|------|
| Skill | Markdown 文档 | 否 |
| 工具 | 串行执行 | 是 |
| 异步工具 | 回调机制 | 可选 |
| 消息总线 | 非阻塞 | 否 |
---
## 10. 建议
1. **避免阻塞**:长时间任务使用 AsyncExecutor
2. **结果显示**:检查 Silent/ResponseHandled 标志
3. **并发控制**:使用 SubTurn 的 concurrencySem

20
go.mod
View File

@@ -6,16 +6,24 @@ require (
charm.land/bubbles/v2 v2.1.0
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.2
github.com/charmbracelet/x/term v0.2.2
github.com/ergochat/readline v0.1.3
github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0
github.com/sipeed/picoclaw v0.0.0
github.com/sipeed/picoclaw v0.2.6
github.com/tursodatabase/libsql-client-go v0.0.0-00010101000000-000000000000
gopkg.in/yaml.v3 v3.0.1
)
// 使用本地第三方库
replace github.com/tursodatabase/libsql-client-go => ./go_modules/libsql-client-go
require (
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/adhocore/gronx v1.19.6 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
@@ -39,11 +47,11 @@ require (
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -52,7 +60,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -85,6 +92,7 @@ require (
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
@@ -92,12 +100,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
modernc.org/sqlite v1.48.0 // indirect
)
// 开发时指向本地 picoclaw
replace github.com/sipeed/picoclaw => /Users/titor/picoclaw

18
go.sum
View File

@@ -16,6 +16,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -76,6 +78,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -159,6 +163,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=
@@ -193,8 +199,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -210,8 +216,8 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -241,8 +247,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

Submodule go_modules/libsql-client-go added at 236aa1ff8a

BIN
hxclaw

Binary file not shown.

33
project.config.yml Normal file
View File

@@ -0,0 +1,33 @@
# hxclaw 项目配置文件
# 模拟流式输出配置
streaming:
line_delay_ms: 1000
last_line_delay_ms: 600
# Markdown 渲染配置
markdown:
theme: dark
line_width: -1
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "
# TTS 语音配置
tts:
enabled: false
port: 9876
auto: true
# 聊天记忆体配置
memory:
enabled: false
auto_session: true
auto_export: true
vector:
base_url: "https://api.siliconflow.cn/v1"
model: "BAAI/bge-m3"
dimension: 1024
max_search_results: 10

355
taolun.md
View File

@@ -46,8 +46,8 @@ hxclaw/
### 6. Markdown 终端渲染
- 使用 charmbracelet 家族库
- glamourMarkdown 渲染(自带代码高亮)
- lipgloss终端样式
- glow代码高亮
- 流程Markdown → ANSI 转义序列 → 终端显示
### 7. 部署方式
@@ -205,4 +205,355 @@ 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 类型
}
```
---
### 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)`,便于错误处理
- 状态文字单独使用颜色样式
- 失败时显示"会话保存异常"(暗红色)