feat: 实现流式输出功能 v0.1.0

- 创建 hxclaw 项目,基于 picoclaw 的 CLI 增强工具
- 实现流式输出,使用 fmt.Print + os.Stdout.Sync() 实时刷新
- 解决 onChunk 回调累积文本导致的重复输出问题
- 使用 strings.Builder 收集完整响应并保存到 session
- 添加讨论记录和更新日志文档
This commit is contained in:
2026-04-11 22:32:43 +08:00
commit d42c70f5ff
8 changed files with 1024 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
package internal
import (
"bufio"
"errors"
"io"
"os"
"path/filepath"
"github.com/ergochat/readline"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// 错误定义
var (
ErrInterrupt = errors.New("interrupt")
ErrEOF = errors.New("EOF")
)
// Logo 是 hxclaw 的 Logo
const Logo = "🦐"
// GetPicoclawHome 返回 picoclaw 的家目录
// 优先级: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
return config.GetHome()
}
// LoadConfig 加载配置文件
// 复用 picoclaw 的配置加载逻辑
func LoadConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(GetConfigPath())
if err != nil {
return nil, err
}
logger.SetLevelFromString(cfg.Gateway.LogLevel)
return cfg, nil
}
// GetConfigPath 获取配置文件路径
func GetConfigPath() string {
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
return configPath
}
return filepath.Join(GetPicoclawHome(), "config.json")
}
// Readline 实例包装
type Readline struct {
rl *readline.Instance
}
// NewReadline 创建一个新的 Readline 实例
func NewReadline(prompt string) (*Readline, error) {
// 确保历史文件目录存在
historyDir := filepath.Dir(filepath.Join(GetPicoclawHome(), ".hxclaw_history"))
os.MkdirAll(historyDir, 0755)
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
HistoryFile: filepath.Join(GetPicoclawHome(), ".hxclaw_history"),
HistoryLimit: 100,
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
if err != nil {
return nil, err
}
return &Readline{rl: rl}, nil
}
// Readline 读取一行输入
func (r *Readline) Readline() (string, error) {
line, err := r.rl.Readline()
if err != nil {
if err == readline.ErrInterrupt {
return "", ErrInterrupt
}
if err == io.EOF {
return "", ErrEOF
}
return "", err
}
return line, nil
}
// Close 关闭 Readline 实例
func (r *Readline) Close() error {
return r.rl.Close()
}
// SimpleReader 简单输入读取器(无历史记录)
type SimpleReader struct {
reader *bufio.Reader
}
// NewSimpleReader 创建一个新的简单读取器
func NewSimpleReader() *SimpleReader {
return &SimpleReader{
reader: bufio.NewReader(os.Stdin),
}
}
// ReadString 读取一行输入
func (r *SimpleReader) ReadString() (string, error) {
line, err := r.reader.ReadString('\n')
if err != nil {
if err == io.EOF {
return "", ErrEOF
}
return "", err
}
// 去掉换行符
if len(line) > 0 && line[len(line)-1] == '\n' {
line = line[:len(line)-1]
}
return line, nil
}

186
cmd/hxclaw/main.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/hxclaw/hxclaw/cmd/hxclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
const Logo = "🦐"
func main() {
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", Logo)
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "错误:加载配置失败: %v\n", err)
os.Exit(1)
}
logger.ConfigureFromEnv()
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:创建 Provider 失败: %v\n", err)
os.Exit(1)
}
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
defer agentLoop.Close()
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("hxclaw", "HxClaw 已初始化",
map[string]any{
"tools_count": startupInfo["tools"].(map[string]any)["count"],
"skills_total": startupInfo["skills"].(map[string]any)["total"],
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", Logo)
interactiveMode(agentLoop, "cli:default")
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", Logo)
rl, err := internal.NewReadline(prompt)
if err != nil {
fmt.Printf("初始化 readline 失败: %v\n", err)
fmt.Println("回退到简单输入模式...")
simpleInteractiveMode(agentLoop, sessionKey)
return
}
defer rl.Close()
for {
line, err := rl.Readline()
if err != nil {
if err == internal.ErrInterrupt || err == internal.ErrEOF {
fmt.Println("\n再见!")
return
}
fmt.Printf("读取输入错误: %v\n", err)
continue
}
input := line
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("再见!")
return
}
runWithStreaming(agentLoop, input, sessionKey)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := internal.NewSimpleReader()
for {
fmt.Print(fmt.Sprintf("%s You: ", Logo))
line, err := reader.ReadString()
if err != nil {
if err == internal.ErrEOF {
fmt.Println("\n再见!")
return
}
fmt.Printf("读取输入错误: %v\n", err)
continue
}
input := line
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("再见!")
return
}
runWithStreaming(agentLoop, input, sessionKey)
}
}
// runWithStreaming 尝试使用流式输出,如果 Provider 不支持则回退到普通模式
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
agentInstance := agentLoop.GetRegistry().GetDefaultAgent()
if agentInstance == nil {
fmt.Println("错误:无法获取 Agent 实例")
return
}
provider := agentInstance.Provider
ctx := context.Background()
// 判断是否支持流式
if sp, ok := provider.(providers.StreamingProvider); ok {
// 从 session 中获取历史消息
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()
fmt.Print("\n")
var result strings.Builder
var printedLen int
_, err := sp.ChatStream(ctx, messages, toolDefs, agentInstance.Model, nil, func(accumulated string) {
if len(accumulated) > printedLen {
fmt.Print(accumulated[printedLen:])
os.Stdout.Sync()
result.WriteString(accumulated[printedLen:])
printedLen = len(accumulated)
}
})
fmt.Println()
if err != nil {
fmt.Printf("流式调用错误: %v\n", err)
return
}
// 将用户消息和回复保存到 session
if result.Len() > 0 {
agentInstance.Sessions.AddMessage(sessionKey, "user", input)
agentInstance.Sessions.AddMessage(sessionKey, "assistant", result.String())
}
} else {
// 回退到普通模式
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("错误: %v\n", err)
return
}
fmt.Printf("\n%s %s\n\n", Logo, response)
}
}