Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7767edd4f | |||
| cc9b2a5c76 | |||
| d27771e020 | |||
| efa10cb0e6 | |||
| 1da98957c2 | |||
| bd48c305ab | |||
| 9653260567 | |||
| 6595a39abe | |||
| 5fd58e44bb | |||
| 38b957d40b | |||
| 85e710ea02 | |||
| 3d9b2d6a90 | |||
| 0427274994 | |||
| c79977496d | |||
| b8d0530118 | |||
| e0b7ea984f | |||
| 1a76243f8f | |||
| dd3c8a03e1 | |||
| e070461fe4 | |||
| 3f9443c14b | |||
| bdad44de7d | |||
| 432a8db938 | |||
| a536375f41 | |||
| 8a631831df | |||
| 98d0b5200b | |||
| af1c509257 | |||
| 9b1c8b5566 | |||
| 4fca6bc081 | |||
| ebee173df9 | |||
| fa86bd5712 | |||
| c15b8c4a80 | |||
| 3c0d161d1a |
65
.gitea/workflows/release.yml
Normal file
65
.gitea/workflows/release.yml
Normal 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
4
.gitignore
vendored
@@ -1,8 +1,8 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.ini
|
*.ini
|
||||||
|
|
||||||
hxclaw
|
hxclaw*
|
||||||
hxclaw.exe
|
hxclaw.exe*
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
.fleet/
|
.fleet/
|
||||||
|
|||||||
73
agents.md
73
agents.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
249
changelog.md
249
changelog.md
@@ -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 家族:
|
||||||
|
- glamour:Markdown 渲染
|
||||||
- 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` 函数实现配置合并逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 代码注释规范
|
||||||
|
|
||||||
|
**问题**:代码缺少注释,后续维护困难
|
||||||
|
|
||||||
|
**纠正**:所有代码添加详细中文注释:
|
||||||
|
- 包级别注释
|
||||||
|
- 结构体注释
|
||||||
|
- 字段注释(使用行内注释 `//`)
|
||||||
|
- 函数注释
|
||||||
|
- 关键逻辑注释
|
||||||
|
|
||||||
|
**知识点**:详细注释是团队协作和后续维护的基础
|
||||||
244
cmd/hxclaw/internal/config.go
Normal file
244
cmd/hxclaw/internal/config.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
7
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||||
|
|||||||
17
project.config.yml
Normal file
17
project.config.yml
Normal 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: "👀 "
|
||||||
179
taolun.md
179
taolun.md
@@ -206,3 +206,182 @@ 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 渲染会显著增加行数和字符数
|
||||||
Reference in New Issue
Block a user