33 Commits

Author SHA1 Message Date
1c6bbe04f1 fix: 修正缩进
Some checks failed
Release / build (push) Failing after 1m25s
2026-04-23 21:35:58 +08:00
c7767edd4f fix: 使用阿里云 apk 镜像源 v3.20 2026-04-23 21:34:28 +08:00
cc9b2a5c76 fix: 添加阿里云镜像源 2026-04-23 21:19:49 +08:00
d27771e020 fix: 按照示例修改 workflow 格式
Some checks failed
Release / build (push) Has been cancelled
2026-04-23 21:15:10 +08:00
efa10cb0e6 fix: 简化 workflow 2026-04-23 21:12:54 +08:00
1da98957c2 fix: 修正 secret 名称为 RELEASE_TOKEN
Some checks failed
Release / build (push) Has been cancelled
2026-04-23 21:03:54 +08:00
bd48c305ab chore: 删除测试 workflow
Some checks failed
Release / build (push) Has been cancelled
2026-04-23 20:52:50 +08:00
9653260567 fix: test 使用完整版
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:52:30 +08:00
6595a39abe fix: 恢复完整 workflow 格式
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:51:50 +08:00
5fd58e44bb fix: 简化 workflow 格式
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:50:48 +08:00
38b957d40b test: 使用 ubuntu 标签
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:49:11 +08:00
85e710ea02 test: 直接使用 debian:latest
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:47:35 +08:00
3d9b2d6a90 test: 使用 debian:latest 镜像
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:46:36 +08:00
0427274994 test: 使用 host label
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:45:09 +08:00
c79977496d test: 使用 debian-slim runner
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:41:13 +08:00
b8d0530118 test: 添加测试 workflow
Some checks failed
Test / test (push) Has been cancelled
2026-04-23 20:39:27 +08:00
e0b7ea984f fix: 更新 Gitea workflow 格式
Some checks failed
Release / build (push) Has been cancelled
2026-04-23 20:33:12 +08:00
1a76243f8f chore: 删除 GitHub workflow,添加 Gitea 本地编译 release workflow
Some checks failed
Release / build (push) Has been cancelled
2026-04-23 20:21:58 +08:00
dd3c8a03e1 feat: 配置系统重构,添加用户配置和中文注释 2026-04-23 20:11:22 +08:00
e070461fe4 fix: 禁用 glamour word wrap,改用 lipgloss.Print 输出
- getWrapWidth() 支持 wrap_width=-1 禁用换行,添加 LINES fallback
- outputLineByLine() 使用 lipgloss.Print 替代 fmt.Println
- 解决 glamour word wrap 导致列表项换行异常的问题
2026-04-17 07:38:36 +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
ebee173df9 ci: 修复工作目录为 /workspace/titor/hxclaw
Some checks failed
Release / build (push) Failing after 19s
2026-04-12 03:18:00 +08:00
fa86bd5712 ci: 修复 go mod download 路径
Some checks failed
Release / build (push) Failing after 20s
2026-04-12 03:17:27 +08:00
c15b8c4a80 ci: 修复 workflow 路径为 .gitea/workflows
Some checks failed
Release / build (push) Failing after 24s
2026-04-12 03:15:12 +08:00
3c0d161d1a ci: 添加 Release CI/CD workflow 2026-04-12 03:08:15 +08:00
13 changed files with 775 additions and 369 deletions

View File

@@ -0,0 +1,65 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
env:
GOPROXY: "https://mirrors.aliyun.com/goproxy/,direct"
steps:
- name: Checkout
run: |
# 使用阿里云 apk 镜像源
echo -e 'https://mirrors.aliyun.com/alpine/v3.20/main/\nhttps://mirrors.aliyun.com/alpine/v3.20/community/' > /etc/apk/repositories
apk add git bash
git clone https://hub.gaomia.site/titor/HxClaw.git /workspace/hxclaw
cp -r /workspace/hxclaw/* /workspace/hxclaw/
cp -r /workspace/hxclaw/.* /workspace/hxclaw/ 2>/dev/null || true
- name: Download dependencies
run: go mod download
- name: Build
run: |
cd /workspace/hxclaw
for p in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
os=${p%/*}
arch=${p#*/}
ext=""
[ "$os" = "windows" ] && ext=".exe"
GOOS=$os GOARCH=$arch go build -o "hxclaw-${os}-${arch}${ext}" ./cmd/hxclaw
done
- name: Checksums
run: sha256sum hxclaw-* > checksums.txt
- name: Release
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
# 使用阿里云 apk 镜像源
echo -e 'https://mirrors.aliyun.com/alpine/v3.20/main/\nhttps://mirrors.aliyun.com/alpine/v3.20/community/' > /etc/apk/repositories
apk add curl jq
TAG_NAME="${GITHUB_REF#refs/tags/}"
RELEASE_BODY=$(git tag -l --format='%(contents)' "$TAG_NAME" 2>/dev/null || git log -1 --format="%s%n%n%b" "$TAG_NAME" 2>/dev/null || echo "Release $TAG_NAME")
RELEASE_RESPONSE=$(curl -s -X POST "https://hub.gaomia.site/api/v1/repos/titor/hxclaw/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${RELEASE_BODY}\"}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
for f in hxclaw-* checksums.txt; do
[ -f "$f" ] && curl -s -X POST "https://hub.gaomia.site/api/v1/repos/titor/hxclaw/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@$f"
done

4
.gitignore vendored
View File

@@ -1,8 +1,8 @@
.DS_Store .DS_Store
*.ini *.ini
hxclaw hxclaw*
hxclaw.exe hxclaw.exe*
.idea/ .idea/
.fleet/ .fleet/

View File

@@ -57,20 +57,54 @@
### v0.1.0 已完成功能 ### v0.1.0 已完成功能
1. **流式输出** 1. **流式输出(新流程)**
- 实时打印 token - 等待 AI 返回完整响应
- Spinner 显示"思考中..." - Markdown 转译
- 第一个 token 到达时停止 spinner - 模拟流式输出(从配置读取速度)
- 效果更好,无残留问题
2. **Markdown 渲染** 2. **Markdown 渲染**
- 使用 glamour 库渲染 Markdown - 使用 glamour 库渲染 Markdown
- 支持多种主题dark, light, dracula, tokyo-night 等) - 支持多种主题dark, light, dracula, tokyo-night 等)
- 通过 GLAMOUR_STYLE 环境变量配置主题 - 通过 project.config.yml 配置主题
3. **重绘逻辑** 3. **项目配置**
- 响应完成后尝试重绘 - 通过 project.config.yml 统一管理配置项
- 使用 termenv 库清除屏幕 - 支持流式速度、渲染主题、Logo 等配置
- ⚠️ 存在轻微残留 bug可接受
---
## 项目配置
### 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: 0 # 自动换行宽度0=自动,>0=固定宽度,-1=禁用)
# UI 配置
ui:
logo: "🦐" # Logo
user_icon: "👀 " # 用户输入提示符
```
配置加载优先级(从高到低):
1. 用户配置 `~/.config/hxclaw/config.yml`
2. 环境变量 `HXCLAW_CONFIG` 指定路径
3. 项目根目录 `project.config.yml`
4. 代码中的默认值
--- ---
@@ -82,32 +116,29 @@
- `charm.land/lipgloss/v2` - 终端样式 - `charm.land/lipgloss/v2` - 终端样式
- `charm.land/x/term` - 终端控制 - `charm.land/x/term` - 终端控制
- `github.com/muesli/termenv` - 终端环境工具 - `github.com/muesli/termenv` - 终端环境工具
- `gopkg.in/yaml.v3` - 配置文件解析
### 配置文件 ### 配置文件
- `cmd/hxclaw/main.go` - 主入口逻辑 - `cmd/hxclaw/main.go` - 主入口逻辑
- `cmd/hxclaw/internal/config.go` - 配置加载(支持用户配置和项目配置合并)
- `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器 - `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器
- `cmd/hxclaw/internal/helpers.go` - 辅助函数 - `cmd/hxclaw/internal/helpers.go` - 辅助函数Readline、SimpleReader
- `cmd/hxclaw/internal/spinner.go` - 加载动画组件
- `cmd/hxclaw/internal/markdown_test.go` - Markdown 测试
--- ---
## 已知问题 ## 已解决
1. **重绘残留**:某些情况下有轻微文本重复(可接受) 1. ~~**重绘残留**:某些情况下有轻微文本重复~~ - 已通过新流程解决
2. **终端兼容性**termenv 在某些终端可能不完全工作
---
## 待优化
1. 优化重绘逻辑,解决残留问题
2. 添加更多主题支持
3. 添加命令-line 参数支持主题选择
--- ---
## 构建命令 ## 构建命令
picoclaw GitHub地址https://github.com/sipeed/picoclaw.git
```bash ```bash
go build -o hxclaw ./cmd/hxclaw go build -o hxclaw ./cmd/hxclaw
``` ```
@@ -120,4 +151,4 @@ go build -o hxclaw ./cmd/hxclaw
- 不要修改 picoclaw 源码 - 不要修改 picoclaw 源码
- 保持代码独立,便于后续版本同步 - 保持代码独立,便于后续版本同步
- 优先实现核心功能,再考虑增强功能 - 优先实现核心功能,再考虑增强功能
- 文档和代码同步更新 - 文档和代码同步更新

View File

@@ -2,58 +2,26 @@
## 版本记录 ## 版本记录
### v0.1.0 (规划中)
- 创建 hxclaw 项目
- 实现流式输出功能
- Markdown 渲染功能(待实现)
- 代码高亮功能(待实现)
---
## 待实现功能
### v0.1.0 (当前) ### v0.1.0 (当前)
- [x] 流式输出功能 - [x] 流式输出功能
- [x] 导入 picoclaw 核心库 - [x] Markdown 渲染功能
- [x] 实现流式 Provider 调用 - [x] 配置系统(支持用户配置和项目配置)
- [x] 实时打印 token - [x] 代码中文注释
- [x] 处理非流式 Provider 回退
- [x] 添加加载动画spinner 组件) ---
- [x] 使用 bubbletea v2 spinner.MiniDot 样式
- [x] 用户输入后显示思考中动画
- [x] 第一个 token 返回后显示思考完成
- [x] 流式输出完成后添加空行分隔
### v0.2.0 (计划) ### v0.2.0 (计划)
- [ ] Markdown 渲染 - [ ] 代码块渲染
- [ ] Markdown 解析 - [ ] 表格渲染
- [ ] 基础样式(粗体、斜体、链接) - [ ] 列表渲染
- [ ] 代码块渲染
- [ ] 表格渲染
- [ ] 列表渲染
### v0.3.0 (计划) ### v0.3.0 (计划)
- [ ] 代码高亮 - [ ] 代码高亮
- [ ] 集成 glow 或类似库 - [ ] 集成 glow 或类似库
- [ ] 支持常见语言语法高亮 - [ ] 支持常见语言语法高亮
---
## 目前进度
- [x] 创建项目目录结构
- [x] 编写讨论记录taolun.md
- [x] 编写更新日志changelog.md
- [x] 编写 AI 行为指南agents.md
- [x] 创建 go.mod
- [x] 实现 main.go 入口
- [x] 实现流式输出核心逻辑
- [x] 编译成功,生成 hxclaw 二进制
- [x] 添加 spinner 加载动画组件
--- ---
@@ -85,14 +53,7 @@
**问题**:不是所有 Provider 都支持流式输出 **问题**:不是所有 Provider 都支持流式输出
**纠正**:需要使用类型断言判断 Provider 是否实现 `providers.StreamingProvider` 接口 **纠正**:需要使用类型断言判断 Provider 是否实现 `providers.StreamingProvider` 接口
```go
if sp, ok := provider.(providers.StreamingProvider); ok {
// 使用 ChatStream
} else {
// 使用普通 Chat
}
```
**知识点**picoclaw 的 Provider 设计使用了接口分离原则,流式是可选能力 **知识点**picoclaw 的 Provider 设计使用了接口分离原则,流式是可选能力
@@ -103,8 +64,8 @@ if sp, ok := provider.(providers.StreamingProvider); ok {
**问题**:如何实现 Markdown 终端渲染 **问题**:如何实现 Markdown 终端渲染
**纠正**:使用 charmbracelet 家族: **纠正**:使用 charmbracelet 家族:
- glamourMarkdown 渲染
- lipgloss样式定义 - lipgloss样式定义
- glow代码高亮
**知识点**charmbracelet 是 Go 终端UI 的事实标准API 设计优雅 **知识点**charmbracelet 是 Go 终端UI 的事实标准API 设计优雅
@@ -122,133 +83,16 @@ if sp, ok := provider.(providers.StreamingProvider); ok {
--- ---
### AgentRegistry 没有 BuildMessages 方法
**问题**:最初尝试调用 agentLoop.GetRegistry().BuildMessages() 构建消息
**纠正**BuildMessages 属于 ContextBuilder不是 AgentRegistry
```go
// 正确方式
agentInstance.ContextBuilder.BuildMessages(history, summary, input, media, channel, chatID, senderID, senderDisplayName)
```
**知识点**picoclaw 代码结构中ContextBuilder 负责消息构建AgentRegistry 负责 agent 管理
---
### ToolDefinitions 获取方式
**问题**:如何获取可用的工具定义列表
**纠正**:通过 ToolRegistry 的 ToProviderDefs 方法:
```go
toolDefs := agentInstance.Tools.ToProviderDefs()
```
**知识点**ToolRegistry 维护工具注册ToProviderDefs 转换为 provider 可用的格式
---
### 流式输出实时刷新
**问题**:流式输出时字符不是实时显示,要等很久才一次性出现
**纠正**:在 onChunk 回调中添加 `os.Stdout.Sync()` 强制刷新 stdout
```go
func(token string) {
fmt.Print(token)
os.Stdout.Sync() // 强制刷新
}
```
**知识点**Go 的 `fmt.Print` 使用缓冲输出,需要手动刷新才能实时显示
---
### Session 历史消息获取
**问题**:如何获取会话历史用于流式调用
**纠正**:通过 `SessionStore` 接口:
```go
history := agentInstance.Sessions.GetHistory(sessionKey)
summary := agentInstance.Sessions.GetSummary(sessionKey)
```
**知识点**`AgentInstance.Sessions` 实现了 `SessionStore` 接口,支持 `GetHistory``GetSummary` 方法
---
### 流式调用后的消息保存
**问题**:流式调用绕过了 agent loop消息没有保存到 session
**纠正**:流式调用后手动保存消息:
```go
agentInstance.Sessions.AddMessage(sessionKey, "user", input)
agentInstance.Sessions.AddMessage(sessionKey, "assistant", result)
```
**知识点**`SessionStore` 接口提供 `AddMessage` 方法,支持 "user" 和 "assistant" 角色
---
### onChunk 回调接收累积文本导致重复输出 ### onChunk 回调接收累积文本导致重复输出
**问题**picoclaw 的 `StreamingProvider` 接口定义 **问题**picoclaw 的 `StreamingProvider` 接口定义 `onChunk func(accumulated string)`,注释说明每次回调时参数是累积的完整文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。
```go
onChunk func(accumulated string)
```
注释说明:"onChunk receives the accumulated text so far (not individual deltas)"。每次回调时参数是累积的完整文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。 **纠正**:使用 `printedLen` 跟踪已打印位置,只打印新增部分
**纠正**:使用 `printedLen` 跟踪已打印位置,只打印新增部分:
```go
var printedLen int
func(accumulated string) {
if len(accumulated) > printedLen {
fmt.Print(accumulated[printedLen:])
printedLen = len(accumulated)
}
}
```
**知识点**picoclaw 故意设计为累积文本,这样可以在任意时刻获取完整内容用于调试 **知识点**picoclaw 故意设计为累积文本,这样可以在任意时刻获取完整内容用于调试
--- ---
### 尝试 uilive 库但只显示最后一行
**问题**:为了实现同行流动效果,尝试使用 `github.com/gosuri/uilive`
**现象**:该库会覆盖每一行,只显示最后一行内容
**纠正**:移除 uilive直接使用 `fmt.Print` + `os.Stdout.Sync()`,让终端自然处理换行
**知识点**uilive 适用于进度条等场景,不适合长文本流式输出
---
### 流式输出期望同行流动但实际换行显示
**问题**:用户期望像 ollama 那样在同行逐字符流动
**最终方案**
```go
fmt.Print(accumulated[printedLen:])
os.Stdout.Sync()
```
效果:
- 字符串自然累积增长
- 终端自动处理换行(满一行自动 wrap
- 保留所有历史输出
- 每次刷新缓冲区确保立即显示
**知识点**:最简单的方案就是最有效的方案,不需要额外库
---
### spinner 组件的 model 更新必须使用返回值 ### spinner 组件的 model 更新必须使用返回值
**问题**spinner 动画不动 **问题**spinner 动画不动
@@ -257,21 +101,7 @@ os.Stdout.Sync()
**纠正**spinner model 是值类型,需要使用返回值更新: **纠正**spinner model 是值类型,需要使用返回值更新:
```go ```go
// 错误写法 s.spinner, _ = s.spinner.Update(msg) // 必须使用返回值更新
func (s *Spinner) tick() {
msg := s.spinner.Tick()
if msg, ok := msg.(spinner.TickMsg); ok {
s.spinner.Update(msg) // 动画不会动!
}
}
// 正确写法
func (s *Spinner) tick() {
msg := s.spinner.Tick()
if msg, ok := msg.(spinner.TickMsg); ok {
s.spinner, _ = s.spinner.Update(msg) // 必须使用返回值更新
}
}
``` ```
**知识点**bubbletea v2 的组件遵循 TEA 架构模式Update 方法返回更新后的 model需要显式使用返回值。 **知识点**bubbletea v2 的组件遵循 TEA 架构模式Update 方法返回更新后的 model需要显式使用返回值。
@@ -282,36 +112,37 @@ func (s *Spinner) tick() {
**问题**spinner 使用 `\r` 回到行首刷新,流式输出也在同一行打印,导致内容混在一起 **问题**spinner 使用 `\r` 回到行首刷新,流式输出也在同一行打印,导致内容混在一起
**现象** **纠正**在第一个 token 时停止 spinner让 spinner 输出 "思考完成." 并换行,然后再开始流式打印
```
⠋ 回答中... 好
⠋ 回答中... 注于
```
**纠正**:在第一个 token 时停止 spinner让 spinner 输出 "思考完成." 并换行,然后再开始流式打印:
```go
if firstToken && len(accumulated) > 0 {
spinner.Stop() // 停止 spinner会打印 "思考完成."
firstToken = false
}
```
**知识点**spinner 和流式输出需要分时工作,不能同时占用同一行。 **知识点**spinner 和流式输出需要分时工作,不能同时占用同一行。
--- ---
### spinner 动画位置和换行策略 ### 配置系统设计
**问题**:用户期望动画在前,文字在后,且需要正确的换行 **问题**:用户需要自定义主题、延迟等配置,但项目配置只有一份
**效果** **纠正**设计多级配置系统:
``` - 用户配置:`~/.config/hxclaw/config.yml`(优先级最高)
思考中... ⠋ -> 用户期望 ⠋ 思考中... - 项目配置:`project.config.yml`
思考完成. -> 用户期望 ⠋ 思考完成. - 环境变量:`HXCLAW_CONFIG` 指定路径
``` - 代码默认值
**纠正** **合并规则**用户配置优先于项目配置,项目配置优先于默认值
- 动画在前,文字在后:`fmt.Printf("\r%s %s", s.spinner.View(), s.text)`
- 换行:"思考完成.\n" + 流式输出后 "fmt.Println()\n"
**知识点**终端输出需要精确控制位置和换行,否则会导致格式错乱。 **实现**`mergeConfig` 函数实现配置合并逻辑
---
### 代码注释规范
**问题**:代码缺少注释,后续维护困难
**纠正**:所有代码添加详细中文注释:
- 包级别注释
- 结构体注释
- 字段注释(使用行内注释 `//`
- 函数注释
- 关键逻辑注释
**知识点**:详细注释是团队协作和后续维护的基础

View File

@@ -0,0 +1,244 @@
// 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"`
}
// 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"`
}
var (
// defaultCfg 默认配置值,当配置文件不存在或字段为空时使用
defaultCfg = Config{
Streaming: StreamingConfig{
LineDelayMs: 1000,
LastLineDelayMs: 600,
},
Markdown: MarkdownConfig{
Theme: "dark",
LineWidth: 0,
},
UI: UIConfig{
Logo: "🦐",
UserIcon: "👀 ",
},
}
cfg *Config // 已合并的配置(用户配置 + 项目配置)
cfgLock sync.RWMutex // 配置读写锁
userCfg *Config // 用户配置文件解析结果
userLock sync.RWMutex // 用户配置读写锁
)
// 用户配置文件路径常量
const (
userConfigDir = ".config/hxclaw" // 用户配置目录(相对于用户家目录)
userConfigFile = "config.yml" // 用户配置文件名
)
// LoadProjectConfig 加载并合并项目配置
// 加载顺序:用户配置(~/.config/hxclaw/config.yml > 项目配置project.config.yml
// 最终配置由两者合并得出,用户配置优先于项目配置
func LoadProjectConfig() error {
cfgLock.Lock()
defer cfgLock.Unlock()
userLock.Lock()
defer userLock.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 &defaultCfg
}
return nil
}
var userCfg Config
if err := yaml.Unmarshal(data, &userCfg); err != nil {
return nil
}
return &userCfg
}
// createUserConfig 创建默认的用户配置文件
// userPath: 配置文件完整路径
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 # 渲染主题dark, light, dracula, tokyo-night 等
line_width: 0 # 自动换行宽度0=自动,>0=固定宽度,-1=禁用)
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "
`
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 userCfg != nil {
if userCfg.Markdown.Theme != "" {
result.Markdown.Theme = userCfg.Markdown.Theme
}
if userCfg.Markdown.LineWidth != 0 {
result.Markdown.LineWidth = userCfg.Markdown.LineWidth
}
if userCfg.UI.Logo != "" {
result.UI.Logo = userCfg.UI.Logo
}
if userCfg.UI.UserIcon != "" {
result.UI.UserIcon = userCfg.UI.UserIcon
}
}
return &result
}
// GetProjectConfig 获取当前生效的配置(线程安全)
// 如果配置未加载,返回默认配置
func GetProjectConfig() *Config {
cfgLock.RLock()
defer cfgLock.RUnlock()
if cfg == nil {
return &defaultCfg
}
return cfg
}
// getUserConfigPath 获取用户配置文件路径
// 路径格式:~/.config/hxclaw/config.yml
// 支持 Windows (USERPROFILE) 和 Unix (HOME) 环境变量
func getUserConfigPath() string {
homeDir := os.Getenv("USERPROFILE")
if homeDir == "" {
homeDir = os.Getenv("HOME")
}
if homeDir == "" {
return ""
}
return filepath.Join(homeDir, userConfigDir, userConfigFile)
}
// getProjectConfigPath 获取项目配置文件路径
// 优先使用环境变量 HXCLAW_CONFIG 指定的路径,否则使用当前目录的 project.config.yml
func getProjectConfigPath() string {
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
return path
}
return filepath.Join(".", "project.config.yml")
}

View File

@@ -1,22 +1,29 @@
// Package internal 包含 hxclaw 的内部工具模块
// 提供配置管理、Markdown 渲染、输入读取等功能
package internal package internal
import ( import (
"os" "os"
"strconv"
"strings" "strings"
"charm.land/glamour/v2" "charm.land/glamour/v2"
"github.com/charmbracelet/x/term"
) )
// RenderMarkdown 将 Markdown 文本渲染为终端友好的格式
// 支持通过配置或环境变量指定渲染主题和换行宽度
func RenderMarkdown(md string) string { func RenderMarkdown(md string) string {
if md == "" { if md == "" {
return "" return ""
} }
style := getStyle() style := getStyle()
wrapWidth := getWrapWidth()
r, err := glamour.NewTermRenderer( r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style), glamour.WithStandardStyle(style),
glamour.WithWordWrap(80), glamour.WithWordWrap(wrapWidth),
) )
if err != nil { if err != nil {
return md return md
@@ -43,10 +50,11 @@ func RenderParagraph(text string) string {
} }
style := getStyle() style := getStyle()
wrapWidth := getWrapWidth()
r, err := glamour.NewTermRenderer( r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style), glamour.WithStandardStyle(style),
glamour.WithWordWrap(80), glamour.WithWordWrap(wrapWidth),
) )
if err != nil { if err != nil {
return text return text
@@ -62,9 +70,42 @@ func RenderParagraph(text string) string {
} }
func getStyle() string { func getStyle() string {
style := "dark" if cfg := GetProjectConfig(); cfg != nil {
if s := os.Getenv("GLAMOUR_STYLE"); s != "" { if cfg.Markdown.Theme != "" {
style = s 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

@@ -1,3 +1,5 @@
// Package internal 包含 hxclaw 的内部工具模块
// 提供配置管理、Markdown 渲染、输入读取等功能
package internal package internal
import ( import (
@@ -9,22 +11,26 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
// SpinnerState 表示加载动画的状态
type SpinnerState int type SpinnerState int
const ( const (
StateThinking SpinnerState = iota StateThinking SpinnerState = iota // 思考中状态
StateAnswering StateAnswering // 回答中状态
StateDone StateDone // 完成状态
) )
// Spinner 加载动画组件,用于显示思考状态
type Spinner struct { type Spinner struct {
text string text string // 显示的文本内容
state SpinnerState state SpinnerState // 当前状态
spinner spinner.Model spinner spinner.Model // 底层动画模型
stopCh chan struct{} stopCh chan struct{} // 停止信号通道
doneCh chan struct{} doneCh chan struct{} // 完成信号通道
} }
// NewSpinner 创建一个新的加载动画实例
// text: 初始显示的文本内容
func NewSpinner(text string) *Spinner { func NewSpinner(text string) *Spinner {
s := spinner.New( s := spinner.New(
spinner.WithSpinner(spinner.MiniDot), spinner.WithSpinner(spinner.MiniDot),
@@ -80,11 +86,11 @@ func (s *Spinner) run() {
} }
func (s *Spinner) render() { 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() os.Stdout.Sync()
} }
func (s *Spinner) clear() { func (s *Spinner) clear() {
fmt.Printf("\r%s 思考完成.\n", s.spinner.View()) fmt.Printf("\r %s 思考完成.\n", s.spinner.View())
os.Stdout.Sync() os.Stdout.Sync()
} }

View File

@@ -1,3 +1,5 @@
// Package main 是 hxclaw 应用程序的入口包
// 提供交互式 CLI 界面和流式输出功能
package main package main
import ( import (
@@ -18,37 +20,53 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
var totalCompletionTokens int // Logo 应用 Logo 字符常量
const Logo = "🦐" const Logo = "🦐"
// main 程序入口函数
// 负责初始化配置、创建 Provider、启动 Agent Loop 和交互式界面
func main() { func main() {
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", Logo) // 加载 hxclaw 项目配置
if err := internal.LoadProjectConfig(); err != nil {
fmt.Fprintf(os.Stderr, "错误:加载项目配置失败: %v\n", err)
os.Exit(1)
}
// 打印应用 Logo 和欢迎信息
logo := internal.GetProjectConfig().UI.Logo
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", logo)
// 加载 picoclaw 配置文件
cfg, err := internal.LoadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "错误:加载配置失败: %v\n", err) fmt.Fprintf(os.Stderr, "错误:加载配置失败: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// 配置日志系统
logger.ConfigureFromEnv() logger.ConfigureFromEnv()
// 创建 AI Provider支持 OpenAI、Claude 等)
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "错误:创建 Provider 失败: %v\n", err) fmt.Fprintf(os.Stderr, "错误:创建 Provider 失败: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// 如果命令行指定了模型 ID覆盖配置文件中的默认模型
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID cfg.Agents.Defaults.ModelName = modelID
} }
// 创建消息总线,用于组件间通信
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
defer msgBus.Close() defer msgBus.Close()
// 创建 Agent Loop处理用户交互和 AI 请求
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
defer agentLoop.Close() defer agentLoop.Close()
// 获取启动信息并打印
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("hxclaw", "HxClaw 已初始化", logger.InfoCF("hxclaw", "HxClaw 已初始化",
map[string]any{ map[string]any{
@@ -57,12 +75,15 @@ func main() {
"skills_available": startupInfo["skills"].(map[string]any)["available"], "skills_available": startupInfo["skills"].(map[string]any)["available"],
}) })
// 打印交互模式提示
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", Logo) fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", Logo)
// 启动交互模式
interactiveMode(agentLoop, "cli:default") interactiveMode(agentLoop, "cli:default")
} }
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", Logo) prompt := internal.GetProjectConfig().UI.UserIcon
rl, err := internal.NewReadline(prompt) rl, err := internal.NewReadline(prompt)
if err != nil { if err != nil {
@@ -101,7 +122,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := internal.NewSimpleReader() reader := internal.NewSimpleReader()
for { for {
fmt.Print(fmt.Sprintf("%s You: ", Logo)) fmt.Print(internal.GetProjectConfig().UI.UserIcon)
line, err := reader.ReadString() line, err := reader.ReadString()
if err != nil { if err != nil {
if err == internal.ErrEOF { if err == internal.ErrEOF {
@@ -126,134 +147,104 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
} }
} }
// runWithStreaming 尝试使用流式输出,如果 Provider 不支持则回退到普通模式 // runWithStreaming 使用 ProcessDirect 处理请求并以模拟流式方式输出结果
// 1. 显示加载动画表示 AI 正在思考
// 2. 调用 Agent 处理用户输入
// 3. 渲染 Markdown 输出并按行延迟显示
// 4. 打印处理耗时
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) { func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
startTime := time.Now() startTime := time.Now()
agentInstance := agentLoop.GetRegistry().GetDefaultAgent() // 创建并启动加载动画
if agentInstance == nil { spinner := internal.NewSpinner("思考中...")
fmt.Println("错误:无法获取 Agent 实例") spinner.Start()
// 调用 AI 处理用户输入
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
// 停止加载动画
spinner.Stop()
// 处理错误
if err != nil {
fmt.Printf("错误: %v\n", err)
return return
} }
provider := agentInstance.Provider // 渲染 Markdown 并输出
ctx := context.Background() rendered := internal.RenderMarkdown(resp)
clearSpinnerLine()
outputLineByLine(rendered)
// 判断是否支持流式 // 打印处理耗时
if sp, ok := provider.(providers.StreamingProvider); ok { elapsed := time.Since(startTime)
// 从 session 中获取历史消息 printElapsed(elapsed)
history := agentInstance.Sessions.GetHistory(sessionKey)
summary := agentInstance.Sessions.GetSummary(sessionKey)
// 使用 ContextBuilder 构建消息,包含历史
messages := agentInstance.ContextBuilder.BuildMessages(
history,
summary,
input,
nil, // media
"cli", // channel
sessionKey,
"", // senderID
"", // senderDisplayName
)
// 获取工具定义
toolDefs := agentInstance.Tools.ToProviderDefs()
// 启动 spinner显示 "思考中..."
spinner := internal.NewSpinner("思考中...")
spinner.Start()
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)
}
})
if err != nil {
spinner.Stop()
fmt.Printf("流式调用错误: %v\n", err)
return
}
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()
}
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)
} else {
fmt.Printf("\n%s %s\n\n", Logo, response)
}
}
} }
// clearSpinnerLine 清除 spinner 行
// 使用终端控制字符清除当前行并移动到行首
func clearSpinnerLine() {
output := termenv.DefaultOutput()
output.ClearLine()
fmt.Print("\r")
os.Stdout.Sync()
}
// outputLineByLine 按行输出文本,模拟打字效果
// 每行之间根据配置延迟,最后一行延迟时间较长
// 空行会直接输出不延迟
func outputLineByLine(text string) {
if text == "" {
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
}
lipgloss.Print(line + "\n")
// 非最后一行使用普通延迟,最后一行使用较长延迟
if i < totalLines-1 {
time.Sleep(lineDelay)
} else {
time.Sleep(lastLineDelay)
}
}
lipgloss.Print("\n")
}
// 输出样式定义
var ( var (
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc80")) iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e")) // 图标样式(金色)
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5c7a9a")) textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32")) // 文本样式(深灰)
) )
func printStats(resp *providers.LLMResponse, elapsed time.Duration) { // printElapsed 打印处理耗时信息
if resp == nil || resp.Usage == nil { // 格式化输出:小于 60 秒显示秒数,否则显示分钟数
return func printElapsed(elapsed time.Duration) {
}
completionTokens := resp.Usage.CompletionTokens
if completionTokens <= 0 {
return
}
totalCompletionTokens += completionTokens
elapsedSec := math.Round(elapsed.Seconds()*10) / 10 elapsedSec := math.Round(elapsed.Seconds()*10) / 10
thisTokens := formatTokens(completionTokens)
totalTokens := formatTokens(totalCompletionTokens)
elapsedStr := formatDuration(elapsedSec) elapsedStr := formatDuration(elapsedSec)
icon := iconStyle.Render("▣ ") 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) fmt.Printf(" %s%s\n\n", icon, text)
} }
// formatTokens 格式化 token 数量
// 1000 以上显示为 k 单位(如 1.5k
func formatTokens(n int) string { func formatTokens(n int) string {
if n >= 1000 { if n >= 1000 {
return fmt.Sprintf("%.1fk", float64(n)/1000) return fmt.Sprintf("%.1fk", float64(n)/1000)
@@ -261,6 +252,8 @@ func formatTokens(n int) string {
return fmt.Sprintf("%d", n) return fmt.Sprintf("%d", n)
} }
// formatDuration 格式化时长字符串
// 小于 60 秒显示秒数(如 12.5s),否则显示分钟数(如 2.5m
func formatDuration(s float64) string { func formatDuration(s float64) string {
if s >= 60 { if s >= 60 {
return fmt.Sprintf("%.1fm", s/60) return fmt.Sprintf("%.1fm", s/60)

7
go.mod
View File

@@ -8,7 +8,8 @@ require (
charm.land/lipgloss/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2
github.com/ergochat/readline v0.1.3 github.com/ergochat/readline v0.1.3
github.com/muesli/termenv v0.16.0 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 ( require (
@@ -92,12 +93,8 @@ require (
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.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/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // 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/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 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

BIN
hxclaw

Binary file not shown.

17
project.config.yml Normal file
View File

@@ -0,0 +1,17 @@
# hxclaw 项目配置文件
# 项目级配置,会覆盖用户配置
# 模拟流式输出配置
streaming:
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
# Markdown 渲染配置
markdown:
theme: light # 渲染主题dark, light, dracula, tokyo-night 等
line_width: 100 # 自动换行宽度0=自动,>0=固定宽度,-1=禁用)
# UI 配置
ui:
logo: "🦐"
user_icon: "👀 "

181
taolun.md
View File

@@ -205,4 +205,183 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3. 换行控制: 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:
theme: dark # 渲染主题dark, light, dracula, tokyo-night 等
line_width: 0 # 自动换行宽度0=自动获取终端宽度)
# UI 配置
ui:
logo: "🦐" # Logo
user_icon: "👀 " # 用户输入提示符
```
#### 配置加载优先级
1. 用户配置 `~/.config/hxclaw/config.yml`
2. 环境变量 `HXCLAW_CONFIG` 指定路径
3. 项目根目录 `project.config.yml`
4. 代码中的默认值
#### 代码实现
```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 渲染会显著增加行数和字符数