feat: 优化流式输出,添加按行延迟和配置化
Some checks failed
Release / build (push) Failing after 22s

- 使用 ProcessDirect 替代 ChatStream,支持工具调用结果显示
- 新增 project.config.yml 统一配置(Logo、用户前缀、流式延迟、Markdown等)
- Markdown 渲染支持自动终端宽度换行
- 按行输出文本,每行延迟可配置
- 简化状态栏,只显示耗时(图标颜色 #f0c75e,文字颜色 #2b2e32)
- spinner 动画右移两个字符
- 用户输入前缀可配置化
This commit is contained in:
2026-04-15 06:13:52 +08:00
parent 432a8db938
commit bdad44de7d
7 changed files with 260 additions and 123 deletions

View File

@@ -57,20 +57,48 @@
### v0.1.0 已完成功能
1. **流式输出**
- 实时打印 token
- Spinner 显示"思考中..."
- 第一个 token 到达时停止 spinner
1. **流式输出(新流程)**
- 等待 AI 返回完整响应
- Markdown 转译
- 模拟流式输出(从配置读取速度)
- 效果更好,无残留问题
2. **Markdown 渲染**
- 使用 glamour 库渲染 Markdown
- 支持多种主题dark, light, dracula, tokyo-night 等)
- 通过 GLAMOUR_STYLE 环境变量配置主题
- 通过 project.config.yml 配置主题
3. **重绘逻辑**
- 响应完成后尝试重绘
- 使用 termenv 库清除屏幕
- ⚠️ 存在轻微残留 bug可接受
3. **项目配置**
- 通过 project.config.yml 统一管理配置项
- 支持流式速度、渲染主题、Logo 等配置
---
## 项目配置
### 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/x/term` - 终端控制
- `github.com/muesli/termenv` - 终端环境工具
- `gopkg.in/yaml.v3` - 配置文件解析
### 配置文件
- `cmd/hxclaw/main.go` - 主入口逻辑
- `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器
- `cmd/hxclaw/internal/helpers.go` - 辅助函数
- `cmd/hxclaw/internal/config.go` - 项目配置加载
---
@@ -100,9 +130,9 @@
## 待优化
1. 优化重绘逻辑,解决残留问题
1. 优化重绘逻辑,解决残留问题(已通过新流程解决)
2. 添加更多主题支持
3. 添加命令-line 参数支持主题选择
3. 添加命令参数支持主题选择
---

View File

@@ -0,0 +1,112 @@
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"`
}
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"`
}
var (
defaultCfg = ProjectConfig{
Streaming: StreamingConfig{
LineDelayMs: 1000,
LastLineDelayMs: 600,
},
Markdown: MarkdownConfig{
GlamourStyle: "dark",
WrapWidth: 0,
},
UI: UIConfig{
Logo: "🦐",
UserPrefix: "👀 ",
},
}
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
}
projCfg = &cfg
return nil
}
func GetProjectConfig() *ProjectConfig {
projCfgLock.RLock()
defer projCfgLock.RUnlock()
if projCfg == nil {
return &defaultCfg
}
return projCfg
}
func getConfigPath() string {
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
return path
}
return filepath.Join(".", "project.config.yml")
}

View File

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

View File

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

View File

@@ -18,12 +18,16 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
var totalCompletionTokens int
const Logo = "🦐"
func main() {
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", Logo)
if err := internal.LoadProjectConfig(); err != nil {
fmt.Fprintf(os.Stderr, "错误:加载项目配置失败: %v\n", err)
os.Exit(1)
}
logo := internal.GetProjectConfig().UI.Logo
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", logo)
cfg, err := internal.LoadConfig()
if err != nil {
@@ -62,7 +66,7 @@ func main() {
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", Logo)
prompt := internal.GetProjectConfig().UI.UserPrefix
rl, err := internal.NewReadline(prompt)
if err != nil {
@@ -101,7 +105,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := internal.NewSimpleReader()
for {
fmt.Print(fmt.Sprintf("%s You: ", Logo))
fmt.Print(internal.GetProjectConfig().UI.UserPrefix)
line, err := reader.ReadString()
if err != nil {
if err == internal.ErrEOF {
@@ -126,131 +130,78 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
}
// runWithStreaming 尝试使用流式输出,如果 Provider 不支持则回退到普通模式
// runWithStreaming 使用 ProcessDirect 处理请求,支持工具调用和结果显示
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
startTime := time.Now()
agentInstance := agentLoop.GetRegistry().GetDefaultAgent()
if agentInstance == nil {
fmt.Println("错误:无法获取 Agent 实例")
spinner := internal.NewSpinner("思考中...")
spinner.Start()
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
spinner.Stop()
if err != nil {
fmt.Printf("错误: %v\n", err)
return
}
provider := agentInstance.Provider
ctx := context.Background()
rendered := internal.RenderMarkdown(resp)
clearSpinnerLine()
outputLineByLine(rendered)
// 判断是否支持流式
if sp, ok := provider.(providers.StreamingProvider); ok {
// 从 session 中获取历史消息
history := agentInstance.Sessions.GetHistory(sessionKey)
summary := agentInstance.Sessions.GetSummary(sessionKey)
elapsed := time.Since(startTime)
printElapsed(elapsed)
}
// 使用 ContextBuilder 构建消息,包含历史
messages := agentInstance.ContextBuilder.BuildMessages(
history,
summary,
input,
nil, // media
"cli", // channel
sessionKey,
"", // senderID
"", // senderDisplayName
)
func clearSpinnerLine() {
output := termenv.DefaultOutput()
output.ClearLine()
fmt.Print("\r")
os.Stdout.Sync()
}
// 获取工具定义
toolDefs := agentInstance.Tools.ToProviderDefs()
func outputLineByLine(text string) {
if text == "" {
return
}
// 启动 spinner显示 "思考中..."
spinner := internal.NewSpinner("思考中...")
spinner.Start()
lines := strings.Split(text, "\n")
totalLines := len(lines)
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)
}
})
cfg := internal.GetProjectConfig()
lineDelay := time.Duration(cfg.Streaming.LineDelayMs) * time.Millisecond
lastLineDelay := time.Duration(cfg.Streaming.LastLineDelayMs) * time.Millisecond
if err != nil {
spinner.Stop()
fmt.Printf("流式调用错误: %v\n", err)
return
for i, line := range lines {
if line == "" {
fmt.Println()
continue
}
if result.Len() > 0 {
allOutput := result.String()
rendered := internal.RenderMarkdown(allOutput)
if rendered != allOutput && rendered != "" {
lines := strings.Count(allOutput, "\n") + 1
output := termenv.DefaultOutput()
output.CursorUp(1)
output.ClearLine()
output.ClearLines(lines)
fmt.Print(rendered)
fmt.Println()
fmt.Println()
} else {
fmt.Println()
fmt.Println()
}
fmt.Println(line)
elapsed := time.Since(startTime)
printStats(resp, elapsed)
agentInstance.Sessions.AddMessage(sessionKey, "user", input)
agentInstance.Sessions.AddMessage(sessionKey, "assistant", allOutput)
}
} else {
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("错误: %v\n", err)
return
}
rendered := internal.RenderMarkdown(response)
if rendered != "" && rendered != response {
fmt.Printf("\n%s\n\n", rendered)
if i < totalLines-1 {
time.Sleep(lineDelay)
} else {
fmt.Printf("\n%s %s\n\n", Logo, response)
time.Sleep(lastLineDelay)
}
}
fmt.Println()
}
var (
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc80"))
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5c7a9a"))
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e"))
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32"))
)
func printStats(resp *providers.LLMResponse, elapsed time.Duration) {
if resp == nil || resp.Usage == nil {
return
}
completionTokens := resp.Usage.CompletionTokens
if completionTokens <= 0 {
return
}
totalCompletionTokens += completionTokens
func printElapsed(elapsed time.Duration) {
elapsedSec := math.Round(elapsed.Seconds()*10) / 10
thisTokens := formatTokens(completionTokens)
totalTokens := formatTokens(totalCompletionTokens)
elapsedStr := formatDuration(elapsedSec)
icon := iconStyle.Render("▣ ")
text := textStyle.Render(fmt.Sprintf("Tokens: %s · 耗时: %s · 总Tokens: %s", thisTokens, elapsedStr, totalTokens))
text := textStyle.Render(fmt.Sprintf("耗时: %s", elapsedStr))
fmt.Printf(" %s%s\n\n", icon, text)
}

2
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/ergochat/readline v0.1.3
github.com/muesli/termenv v0.16.0
github.com/sipeed/picoclaw v0.2.6
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -92,7 +93,6 @@ require (
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

16
project.config.yml Normal file
View File

@@ -0,0 +1,16 @@
# 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: "👀 "