Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e5cd82c3 | |||
| 3f9443c14b | |||
| bdad44de7d | |||
| 432a8db938 | |||
| a536375f41 | |||
| 8a631831df | |||
| 98d0b5200b | |||
| af1c509257 | |||
| 9b1c8b5566 | |||
| 4fca6bc081 | |||
| ebee173df9 | |||
| fa86bd5712 | |||
| c15b8c4a80 | |||
| 3c0d161d1a |
61
.gitea/workflows/release.yml
Normal file
61
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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 add git bash
|
||||||
|
rm -rf /workspace
|
||||||
|
git clone https://hub.gaomia.site/titor/hxclaw.git /workspace
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: cd /workspace && go mod download
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd /workspace
|
||||||
|
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: cd /workspace && sha256sum hxclaw-* > checksums.txt
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: "${{ secrets.release_token }}"
|
||||||
|
run: |
|
||||||
|
cd /workspace
|
||||||
|
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
|
||||||
57
.github/workflows/release.yml
vendored
Normal file
57
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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 add git bash
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
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 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
|
||||||
56
agents.md
56
agents.md
@@ -57,20 +57,48 @@
|
|||||||
|
|
||||||
### 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:
|
||||||
|
simulated_speed_ms: 30 # 模拟流式输出速度(毫秒/字符)
|
||||||
|
|
||||||
|
# Markdown 渲染配置
|
||||||
|
markdown:
|
||||||
|
glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等
|
||||||
|
|
||||||
|
# UI 配置
|
||||||
|
ui:
|
||||||
|
logo: "🦐"
|
||||||
|
```
|
||||||
|
|
||||||
|
配置加载优先级:
|
||||||
|
1. 环境变量 `HXCLAW_CONFIG` 指定路径
|
||||||
|
2. 项目根目录 `project.config.yml`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,12 +110,14 @@
|
|||||||
- `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/markdown.go` - Markdown 渲染器
|
- `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器
|
||||||
- `cmd/hxclaw/internal/helpers.go` - 辅助函数
|
- `cmd/hxclaw/internal/helpers.go` - 辅助函数
|
||||||
|
- `cmd/hxclaw/internal/config.go` - 项目配置加载
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -100,14 +130,16 @@
|
|||||||
|
|
||||||
## 待优化
|
## 待优化
|
||||||
|
|
||||||
1. 优化重绘逻辑,解决残留问题
|
1. 优化重绘逻辑,解决残留问题(已通过新流程解决)
|
||||||
2. 添加更多主题支持
|
2. 添加更多主题支持
|
||||||
3. 添加命令-line 参数支持主题选择
|
3. 添加命令行参数支持主题选择
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 构建命令
|
## 构建命令
|
||||||
|
|
||||||
|
picoclaw GitHub地址:https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o hxclaw ./cmd/hxclaw
|
go build -o hxclaw ./cmd/hxclaw
|
||||||
```
|
```
|
||||||
@@ -120,4 +152,4 @@ go build -o hxclaw ./cmd/hxclaw
|
|||||||
- 不要修改 picoclaw 源码
|
- 不要修改 picoclaw 源码
|
||||||
- 保持代码独立,便于后续版本同步
|
- 保持代码独立,便于后续版本同步
|
||||||
- 优先实现核心功能,再考虑增强功能
|
- 优先实现核心功能,再考虑增强功能
|
||||||
- 文档和代码同步更新
|
- 文档和代码同步更新
|
||||||
|
|||||||
14
changelog.md
14
changelog.md
@@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
## 版本记录
|
## 版本记录
|
||||||
|
|
||||||
### v0.1.0 (规划中)
|
### v0.2.0
|
||||||
|
|
||||||
|
- 新增 TTS 语音朗读功能
|
||||||
|
- 集成 mimo-tts client 功能,通过 TCP 连接本地 daemon
|
||||||
|
- 支持配置文件开关(tts.enabled)
|
||||||
|
- 支持命令行切换(/tts on/off/status)
|
||||||
|
- 支持临时 TTS 前缀(`T 消息` 临时开启)
|
||||||
|
- 动态提示符显示 TTS 状态(👀 🔊)
|
||||||
|
- 静默失败处理(网络异常时警告日志)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
- 创建 hxclaw 项目
|
- 创建 hxclaw 项目
|
||||||
- 实现流式输出功能
|
- 实现流式输出功能
|
||||||
|
|||||||
127
cmd/hxclaw/internal/config.go
Normal file
127
cmd/hxclaw/internal/config.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectConfig struct {
|
||||||
|
Streaming StreamingConfig `yaml:"streaming"`
|
||||||
|
Markdown MarkdownConfig `yaml:"markdown"`
|
||||||
|
UI UIConfig `yaml:"ui"`
|
||||||
|
TTS TTSConfig `yaml:"tts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamingConfig struct {
|
||||||
|
LineDelayMs int `yaml:"line_delay_ms"`
|
||||||
|
LastLineDelayMs int `yaml:"last_line_delay_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkdownConfig struct {
|
||||||
|
GlamourStyle string `yaml:"glamour_style"`
|
||||||
|
WrapWidth int `yaml:"wrap_width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UIConfig struct {
|
||||||
|
Logo string `yaml:"logo"`
|
||||||
|
UserPrefix string `yaml:"user_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TTSConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Auto bool `yaml:"auto"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultCfg = ProjectConfig{
|
||||||
|
Streaming: StreamingConfig{
|
||||||
|
LineDelayMs: 1000,
|
||||||
|
LastLineDelayMs: 600,
|
||||||
|
},
|
||||||
|
Markdown: MarkdownConfig{
|
||||||
|
GlamourStyle: "dark",
|
||||||
|
WrapWidth: 0,
|
||||||
|
},
|
||||||
|
UI: UIConfig{
|
||||||
|
Logo: "🦐",
|
||||||
|
UserPrefix: "👀 ",
|
||||||
|
},
|
||||||
|
TTS: TTSConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Port: 9876,
|
||||||
|
Auto: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
projCfg *ProjectConfig
|
||||||
|
projCfgLock sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadProjectConfig() error {
|
||||||
|
projCfgLock.Lock()
|
||||||
|
defer projCfgLock.Unlock()
|
||||||
|
|
||||||
|
cfgPath := getConfigPath()
|
||||||
|
if cfgPath == "" {
|
||||||
|
projCfg = &defaultCfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
projCfg = &defaultCfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg ProjectConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Streaming.LineDelayMs <= 0 {
|
||||||
|
cfg.Streaming.LineDelayMs = defaultCfg.Streaming.LineDelayMs
|
||||||
|
}
|
||||||
|
if cfg.Streaming.LastLineDelayMs <= 0 {
|
||||||
|
cfg.Streaming.LastLineDelayMs = defaultCfg.Streaming.LastLineDelayMs
|
||||||
|
}
|
||||||
|
if cfg.Markdown.GlamourStyle == "" {
|
||||||
|
cfg.Markdown.GlamourStyle = defaultCfg.Markdown.GlamourStyle
|
||||||
|
}
|
||||||
|
if cfg.Markdown.WrapWidth < 0 {
|
||||||
|
cfg.Markdown.WrapWidth = 0
|
||||||
|
}
|
||||||
|
if cfg.UI.Logo == "" {
|
||||||
|
cfg.UI.Logo = defaultCfg.UI.Logo
|
||||||
|
}
|
||||||
|
if cfg.UI.UserPrefix == "" {
|
||||||
|
cfg.UI.UserPrefix = defaultCfg.UI.UserPrefix
|
||||||
|
}
|
||||||
|
if cfg.TTS.Port <= 0 {
|
||||||
|
cfg.TTS.Port = defaultCfg.TTS.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
projCfg = &cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetProjectConfig() *ProjectConfig {
|
||||||
|
projCfgLock.RLock()
|
||||||
|
defer projCfgLock.RUnlock()
|
||||||
|
if projCfg == nil {
|
||||||
|
return &defaultCfg
|
||||||
|
}
|
||||||
|
return projCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigPath() string {
|
||||||
|
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(".", "project.config.yml")
|
||||||
|
}
|
||||||
@@ -49,7 +49,8 @@ func GetConfigPath() string {
|
|||||||
|
|
||||||
// Readline 实例包装
|
// Readline 实例包装
|
||||||
type Readline struct {
|
type Readline struct {
|
||||||
rl *readline.Instance
|
rl *readline.Instance
|
||||||
|
basePrompt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReadline 创建一个新的 Readline 实例
|
// NewReadline 创建一个新的 Readline 实例
|
||||||
@@ -68,7 +69,18 @@ func NewReadline(prompt string) (*Readline, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 读取一行输入
|
// Readline 读取一行输入
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
|
"github.com/charmbracelet/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RenderMarkdown(md string) string {
|
func RenderMarkdown(md string) string {
|
||||||
@@ -13,10 +15,11 @@ func RenderMarkdown(md 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 md
|
return md
|
||||||
@@ -43,10 +46,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 +66,33 @@ 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.GlamourStyle != "" {
|
||||||
style = s
|
return cfg.Markdown.GlamourStyle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return style
|
if s := os.Getenv("GLAMOUR_STYLE"); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWrapWidth() int {
|
||||||
|
if cfg := GetProjectConfig(); cfg != nil {
|
||||||
|
if cfg.Markdown.WrapWidth > 0 {
|
||||||
|
return cfg.Markdown.WrapWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cols := os.Getenv("COLUMNS"); cols != "" {
|
||||||
|
if w, err := strconv.Atoi(cols); err == nil && w > 0 {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
width, _, err := term.GetSize(0)
|
||||||
|
if err != nil || width <= 0 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return width
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
var totalCompletionTokens int
|
|
||||||
|
|
||||||
const Logo = "🦐"
|
const Logo = "🦐"
|
||||||
|
|
||||||
func main() {
|
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()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +66,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
prompt := fmt.Sprintf("%s You: ", Logo)
|
basePrompt := internal.GetProjectConfig().UI.UserPrefix
|
||||||
|
prompt := internal.GetTTSPrompt(basePrompt)
|
||||||
|
|
||||||
rl, err := internal.NewReadline(prompt)
|
rl, err := internal.NewReadline(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,6 +78,11 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
}
|
}
|
||||||
defer rl.Close()
|
defer rl.Close()
|
||||||
|
|
||||||
|
ttsCfg := internal.GetProjectConfig().TTS
|
||||||
|
if ttsCfg.Enabled {
|
||||||
|
internal.SetTTSEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, err := rl.Readline()
|
line, err := rl.Readline()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,14 +104,37 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runWithStreaming(agentLoop, input, sessionKey)
|
isTempTTS := false
|
||||||
|
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
|
||||||
|
input = strings.TrimPrefix(input, "T")
|
||||||
|
input = strings.TrimPrefix(input, " ")
|
||||||
|
isTempTTS = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(input, "/tts") {
|
||||||
|
handleTTSCommand(input, rl, basePrompt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTempTTS {
|
||||||
|
enabled := internal.ToggleTTS()
|
||||||
|
if enabled {
|
||||||
|
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := internal.NewSimpleReader()
|
reader := internal.NewSimpleReader()
|
||||||
|
ttsCfg := internal.GetProjectConfig().TTS
|
||||||
|
if ttsCfg.Enabled {
|
||||||
|
internal.SetTTSEnabled(true)
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", Logo))
|
fmt.Print(internal.GetTTSPrompt(internal.GetProjectConfig().UI.UserPrefix))
|
||||||
line, err := reader.ReadString()
|
line, err := reader.ReadString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == internal.ErrEOF {
|
if err == internal.ErrEOF {
|
||||||
@@ -122,135 +155,103 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runWithStreaming(agentLoop, input, sessionKey)
|
isTempTTS := false
|
||||||
|
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
|
||||||
|
input = strings.TrimPrefix(input, "T")
|
||||||
|
input = strings.TrimPrefix(input, " ")
|
||||||
|
isTempTTS = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(input, "/tts") {
|
||||||
|
handleTTSCommandSimple(input)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTempTTS {
|
||||||
|
internal.ToggleTTS()
|
||||||
|
}
|
||||||
|
|
||||||
|
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runWithStreaming 尝试使用流式输出,如果 Provider 不支持则回退到普通模式
|
// runWithStreaming 使用 ProcessDirect 处理请求,支持工具调用和结果显示
|
||||||
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
|
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string, tempTTS bool) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
agentInstance := agentLoop.GetRegistry().GetDefaultAgent()
|
spinner := internal.NewSpinner("思考中...")
|
||||||
if agentInstance == nil {
|
spinner.Start()
|
||||||
fmt.Println("错误:无法获取 Agent 实例")
|
|
||||||
|
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
|
||||||
|
|
||||||
|
spinner.Stop()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("错误: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := agentInstance.Provider
|
rendered := internal.RenderMarkdown(resp)
|
||||||
ctx := context.Background()
|
clearSpinnerLine()
|
||||||
|
outputLineByLine(rendered)
|
||||||
|
|
||||||
// 判断是否支持流式
|
ttsCfg := internal.GetProjectConfig().TTS
|
||||||
if sp, ok := provider.(providers.StreamingProvider); ok {
|
if ttsCfg.Enabled || tempTTS || internal.IsTTSEnabled() {
|
||||||
// 从 session 中获取历史消息
|
go internal.SpeakText(resp)
|
||||||
history := agentInstance.Sessions.GetHistory(sessionKey)
|
}
|
||||||
summary := agentInstance.Sessions.GetSummary(sessionKey)
|
|
||||||
|
|
||||||
// 使用 ContextBuilder 构建消息,包含历史
|
elapsed := time.Since(startTime)
|
||||||
messages := agentInstance.ContextBuilder.BuildMessages(
|
printElapsed(elapsed)
|
||||||
history,
|
}
|
||||||
summary,
|
|
||||||
input,
|
|
||||||
nil, // media
|
|
||||||
"cli", // channel
|
|
||||||
sessionKey,
|
|
||||||
"", // senderID
|
|
||||||
"", // senderDisplayName
|
|
||||||
)
|
|
||||||
|
|
||||||
// 获取工具定义
|
func clearSpinnerLine() {
|
||||||
toolDefs := agentInstance.Tools.ToProviderDefs()
|
output := termenv.DefaultOutput()
|
||||||
|
output.ClearLine()
|
||||||
|
fmt.Print("\r")
|
||||||
|
os.Stdout.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
// 启动 spinner,显示 "思考中..."
|
func outputLineByLine(text string) {
|
||||||
spinner := internal.NewSpinner("思考中...")
|
if text == "" {
|
||||||
spinner.Start()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Print("\n")
|
lines := strings.Split(text, "\n")
|
||||||
var result strings.Builder
|
totalLines := len(lines)
|
||||||
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 {
|
cfg := internal.GetProjectConfig()
|
||||||
spinner.Stop()
|
lineDelay := time.Duration(cfg.Streaming.LineDelayMs) * time.Millisecond
|
||||||
fmt.Printf("流式调用错误: %v\n", err)
|
lastLineDelay := time.Duration(cfg.Streaming.LastLineDelayMs) * time.Millisecond
|
||||||
return
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
fmt.Println()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Len() > 0 {
|
fmt.Println(line)
|
||||||
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)
|
if i < totalLines-1 {
|
||||||
printStats(resp, elapsed)
|
time.Sleep(lineDelay)
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
fmt.Printf("\n%s %s\n\n", Logo, response)
|
time.Sleep(lastLineDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func printElapsed(elapsed time.Duration) {
|
||||||
if resp == nil || resp.Usage == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,3 +268,66 @@ func formatDuration(s float64) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.1fs", s)
|
return fmt.Sprintf("%.1fs", s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleTTSCommand(input string, rl *internal.Readline, basePrompt string) {
|
||||||
|
args := strings.Fields(input)
|
||||||
|
if len(args) == 1 {
|
||||||
|
enabled := internal.ToggleTTS()
|
||||||
|
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||||
|
status := "关闭"
|
||||||
|
if enabled {
|
||||||
|
status = "开启"
|
||||||
|
}
|
||||||
|
fmt.Printf("TTS 已%s\n", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[1] {
|
||||||
|
case "on":
|
||||||
|
internal.SetTTSEnabled(true)
|
||||||
|
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||||
|
fmt.Println("TTS 已开启")
|
||||||
|
case "off":
|
||||||
|
internal.SetTTSEnabled(false)
|
||||||
|
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||||
|
fmt.Println("TTS 已关闭")
|
||||||
|
case "status":
|
||||||
|
status := "关闭"
|
||||||
|
if internal.IsTTSEnabled() {
|
||||||
|
status = "开启"
|
||||||
|
}
|
||||||
|
fmt.Printf("TTS 状态: %s\n", status)
|
||||||
|
default:
|
||||||
|
fmt.Println("用法: /tts [on|off|status]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTTSCommandSimple(input string) {
|
||||||
|
args := strings.Fields(input)
|
||||||
|
if len(args) == 1 {
|
||||||
|
internal.ToggleTTS()
|
||||||
|
status := "关闭"
|
||||||
|
if internal.IsTTSEnabled() {
|
||||||
|
status = "开启"
|
||||||
|
}
|
||||||
|
fmt.Printf("TTS 已%s\n", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[1] {
|
||||||
|
case "on":
|
||||||
|
internal.SetTTSEnabled(true)
|
||||||
|
fmt.Println("TTS 已开启")
|
||||||
|
case "off":
|
||||||
|
internal.SetTTSEnabled(false)
|
||||||
|
fmt.Println("TTS 已关闭")
|
||||||
|
case "status":
|
||||||
|
status := "关闭"
|
||||||
|
if internal.IsTTSEnabled() {
|
||||||
|
status = "开启"
|
||||||
|
}
|
||||||
|
fmt.Printf("TTS 状态: %s\n", status)
|
||||||
|
default:
|
||||||
|
fmt.Println("用法: /tts [on|off|status]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
7
go.mod
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=
|
||||||
|
|||||||
22
project.config.yml
Normal file
22
project.config.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# hxclaw 项目配置文件
|
||||||
|
|
||||||
|
# 模拟流式输出配置
|
||||||
|
streaming:
|
||||||
|
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
|
||||||
|
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
|
||||||
|
|
||||||
|
# Markdown 渲染配置
|
||||||
|
markdown:
|
||||||
|
glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等
|
||||||
|
wrap_width: 0 # 自动换行宽度(0=自动获取终端宽度)
|
||||||
|
|
||||||
|
# UI 配置
|
||||||
|
ui:
|
||||||
|
logo: "🦐"
|
||||||
|
user_prefix: "👀 "
|
||||||
|
|
||||||
|
# TTS 语音配置
|
||||||
|
tts:
|
||||||
|
enabled: false # 全局开关(默认关闭)
|
||||||
|
port: 9876 # mimo-tts daemon 端口
|
||||||
|
auto: true # AI 回复后自动朗读
|
||||||
178
taolun.md
178
taolun.md
@@ -205,4 +205,180 @@ 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:
|
||||||
|
glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等
|
||||||
|
wrap_width: 0 # 自动换行宽度(0=自动获取终端宽度)
|
||||||
|
|
||||||
|
# UI 配置
|
||||||
|
ui:
|
||||||
|
logo: "🦐" # Logo
|
||||||
|
user_prefix: "👀 " # 用户输入前缀
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 配置加载优先级
|
||||||
|
|
||||||
|
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 渲染会显著增加行数和字符数
|
||||||
Reference in New Issue
Block a user