diff --git a/agents.md b/agents.md index 1ecd91d..5521a95 100644 --- a/agents.md +++ b/agents.md @@ -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. 添加命令行参数支持主题选择 --- diff --git a/cmd/hxclaw/internal/config.go b/cmd/hxclaw/internal/config.go new file mode 100644 index 0000000..78a9d62 --- /dev/null +++ b/cmd/hxclaw/internal/config.go @@ -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") +} diff --git a/cmd/hxclaw/internal/markdown.go b/cmd/hxclaw/internal/markdown.go index 42967e0..f5fc0c3 100644 --- a/cmd/hxclaw/internal/markdown.go +++ b/cmd/hxclaw/internal/markdown.go @@ -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 } diff --git a/cmd/hxclaw/internal/spinner.go b/cmd/hxclaw/internal/spinner.go index e2a454d..aa26c90 100644 --- a/cmd/hxclaw/internal/spinner.go +++ b/cmd/hxclaw/internal/spinner.go @@ -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() } diff --git a/cmd/hxclaw/main.go b/cmd/hxclaw/main.go index c3e0ab6..ac013d6 100644 --- a/cmd/hxclaw/main.go +++ b/cmd/hxclaw/main.go @@ -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) } diff --git a/go.mod b/go.mod index 1a67fce..2425f6a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/project.config.yml b/project.config.yml new file mode 100644 index 0000000..c5513cc --- /dev/null +++ b/project.config.yml @@ -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: "👀 " \ No newline at end of file