diff --git a/.gitignore b/.gitignore index 28a7c0b..a804994 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .DS_Store *.ini -hxclaw -hxclaw.exe +hxclaw* +hxclaw.exe* .idea/ .fleet/ diff --git a/agents.md b/agents.md index 5521a95..f13a797 100644 --- a/agents.md +++ b/agents.md @@ -78,27 +78,33 @@ ### project.config.yml -配置文件位于项目根目录: +项目配置文件位于项目根目录: ```yaml # hxclaw 项目配置文件 +# 项目级配置,会覆盖默认配置但被用户配置覆盖 # 模拟流式输出配置 streaming: - simulated_speed_ms: 30 # 模拟流式输出速度(毫秒/字符) + line_delay_ms: 1000 # 每行输出后的延迟(毫秒) + last_line_delay_ms: 600 # 最后一行延迟(毫秒) # Markdown 渲染配置 markdown: - glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等 + theme: dark # 渲染主题:dark, light, dracula, tokyo-night 等 + line_width: 0 # 自动换行宽度(0=自动,>0=固定宽度,-1=禁用) # UI 配置 ui: - logo: "🦐" + logo: "🦐" # Logo + user_icon: "👀 " # 用户输入提示符 ``` -配置加载优先级: -1. 环境变量 `HXCLAW_CONFIG` 指定路径 -2. 项目根目录 `project.config.yml` +配置加载优先级(从高到低): +1. 用户配置 `~/.config/hxclaw/config.yml` +2. 环境变量 `HXCLAW_CONFIG` 指定路径 +3. 项目根目录 `project.config.yml` +4. 代码中的默认值 --- @@ -115,24 +121,17 @@ ui: ### 配置文件 - `cmd/hxclaw/main.go` - 主入口逻辑 +- `cmd/hxclaw/internal/config.go` - 配置加载(支持用户配置和项目配置合并) - `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器 -- `cmd/hxclaw/internal/helpers.go` - 辅助函数 -- `cmd/hxclaw/internal/config.go` - 项目配置加载 +- `cmd/hxclaw/internal/helpers.go` - 辅助函数(Readline、SimpleReader) +- `cmd/hxclaw/internal/spinner.go` - 加载动画组件 +- `cmd/hxclaw/internal/markdown_test.go` - Markdown 测试 --- -## 已知问题 +## 已解决 -1. **重绘残留**:某些情况下有轻微文本重复(可接受) -2. **终端兼容性**:termenv 在某些终端可能不完全工作 - ---- - -## 待优化 - -1. 优化重绘逻辑,解决残留问题(已通过新流程解决) -2. 添加更多主题支持 -3. 添加命令行参数支持主题选择 +1. ~~**重绘残留**:某些情况下有轻微文本重复~~ - 已通过新流程解决 --- diff --git a/changelog.md b/changelog.md index 778263c..5ce67cb 100644 --- a/changelog.md +++ b/changelog.md @@ -2,58 +2,26 @@ ## 版本记录 -### v0.1.0 (规划中) +### v0.1.0 -- 创建 hxclaw 项目 -- 实现流式输出功能 -- Markdown 渲染功能(待实现) -- 代码高亮功能(待实现) +- [x] 流式输出功能 +- [x] Markdown 渲染功能 +- [x] 配置系统(支持用户配置和项目配置) +- [x] 代码中文注释 --- -## 待实现功能 - -### v0.1.0 (当前) - -- [x] 流式输出功能 - - [x] 导入 picoclaw 核心库 - - [x] 实现流式 Provider 调用 - - [x] 实时打印 token - - [x] 处理非流式 Provider 回退 - - [x] 添加加载动画(spinner 组件) - - [x] 使用 bubbletea v2 spinner.MiniDot 样式 - - [x] 用户输入后显示思考中动画 - - [x] 第一个 token 返回后显示思考完成 - - [x] 流式输出完成后添加空行分隔 - ### v0.2.0 (计划) -- [ ] Markdown 渲染 - - [ ] Markdown 解析 - - [ ] 基础样式(粗体、斜体、链接) - - [ ] 代码块渲染 - - [ ] 表格渲染 - - [ ] 列表渲染 +- [ ] 代码块渲染 +- [ ] 表格渲染 +- [ ] 列表渲染 ### v0.3.0 (计划) - [ ] 代码高亮 - - [ ] 集成 glow 或类似库 - - [ ] 支持常见语言语法高亮 - ---- - -## 目前进度 - -- [x] 创建项目目录结构 -- [x] 编写讨论记录(taolun.md) -- [x] 编写更新日志(changelog.md) -- [x] 编写 AI 行为指南(agents.md) -- [x] 创建 go.mod -- [x] 实现 main.go 入口 -- [x] 实现流式输出核心逻辑 -- [x] 编译成功,生成 hxclaw 二进制 -- [x] 添加 spinner 加载动画组件 +- [ ] 集成 glow 或类似库 +- [ ] 支持常见语言语法高亮 --- @@ -85,14 +53,7 @@ **问题**:不是所有 Provider 都支持流式输出 -**纠正**:需要使用类型断言判断 Provider 是否实现 `providers.StreamingProvider` 接口: -```go -if sp, ok := provider.(providers.StreamingProvider); ok { - // 使用 ChatStream -} else { - // 使用普通 Chat -} -``` +**纠正**:需要使用类型断言判断 Provider 是否实现 `providers.StreamingProvider` 接口 **知识点**:picoclaw 的 Provider 设计使用了接口分离原则,流式是可选能力 @@ -103,8 +64,8 @@ if sp, ok := provider.(providers.StreamingProvider); ok { **问题**:如何实现 Markdown 终端渲染 **纠正**:使用 charmbracelet 家族: +- glamour:Markdown 渲染 - lipgloss:样式定义 -- glow:代码高亮 **知识点**: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 回调接收累积文本导致重复输出 -**问题**:picoclaw 的 `StreamingProvider` 接口定义: -```go -onChunk func(accumulated string) -``` +**问题**:picoclaw 的 `StreamingProvider` 接口定义 `onChunk func(accumulated string)`,注释说明每次回调时参数是累积的完整文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。 -注释说明:"onChunk receives the accumulated text so far (not individual deltas)"。每次回调时参数是累积的完整文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。 - -**纠正**:使用 `printedLen` 跟踪已打印位置,只打印新增部分: -```go -var printedLen int -func(accumulated string) { - if len(accumulated) > printedLen { - fmt.Print(accumulated[printedLen:]) - printedLen = len(accumulated) - } -} -``` +**纠正**:使用 `printedLen` 跟踪已打印位置,只打印新增部分 **知识点**: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 动画不动 @@ -257,21 +101,7 @@ os.Stdout.Sync() **纠正**:spinner model 是值类型,需要使用返回值更新: ```go -// 错误写法 -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) // 必须使用返回值更新 - } -} +s.spinner, _ = s.spinner.Update(msg) // 必须使用返回值更新 ``` **知识点**:bubbletea v2 的组件遵循 TEA 架构模式,Update 方法返回更新后的 model,需要显式使用返回值。 @@ -282,36 +112,37 @@ func (s *Spinner) tick() { **问题**:spinner 使用 `\r` 回到行首刷新,流式输出也在同一行打印,导致内容混在一起 -**现象**: -``` -⠋ 回答中... 好 -⠋ 回答中... 注于 -``` - -**纠正**:在第一个 token 时停止 spinner,让 spinner 输出 "思考完成." 并换行,然后再开始流式打印: -```go -if firstToken && len(accumulated) > 0 { - spinner.Stop() // 停止 spinner,会打印 "思考完成." - firstToken = false -} -``` +**纠正**:在第一个 token 时停止 spinner,让 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" +**合并规则**:用户配置优先于项目配置,项目配置优先于默认值 -**知识点**:终端输出需要精确控制位置和换行,否则会导致格式错乱。 \ No newline at end of file +**实现**:`mergeConfig` 函数实现配置合并逻辑 + +--- + +### 代码注释规范 + +**问题**:代码缺少注释,后续维护困难 + +**纠正**:所有代码添加详细中文注释: +- 包级别注释 +- 结构体注释 +- 字段注释(使用行内注释 `//`) +- 函数注释 +- 关键逻辑注释 + +**知识点**:详细注释是团队协作和后续维护的基础 \ No newline at end of file diff --git a/cmd/hxclaw/internal/config.go b/cmd/hxclaw/internal/config.go index 78a9d62..a03dc0d 100644 --- a/cmd/hxclaw/internal/config.go +++ b/cmd/hxclaw/internal/config.go @@ -1,3 +1,5 @@ +// Package internal 包含 hxclaw 的内部工具模块 +// 提供配置管理、Markdown 渲染、输入读取等功能 package internal import ( @@ -8,105 +10,235 @@ import ( "gopkg.in/yaml.v3" ) -type ProjectConfig struct { +// Config 是项目配置结构体,包含流式输出、Markdown 渲染和 UI 相关配置 +type Config struct { + // Streaming 流式输出配置 Streaming StreamingConfig `yaml:"streaming"` - Markdown MarkdownConfig `yaml:"markdown"` - UI UIConfig `yaml:"ui"` + // Markdown Markdown 渲染配置 + Markdown MarkdownConfig `yaml:"markdown"` + // UI UI 显示配置 + UI UIConfig `yaml:"ui"` } +// StreamingConfig 流式输出配置,控制模拟打字效果的延迟时间 type StreamingConfig struct { - LineDelayMs int `yaml:"line_delay_ms"` + // LineDelayMs 每行输出延迟(毫秒) + LineDelayMs int `yaml:"line_delay_ms"` + // LastLineDelayMs 最后一行输出延迟(毫秒) LastLineDelayMs int `yaml:"last_line_delay_ms"` } +// MarkdownConfig Markdown 渲染配置,控制渲染主题和换行行为 type MarkdownConfig struct { - GlamourStyle string `yaml:"glamour_style"` - WrapWidth int `yaml:"wrap_width"` + // 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 string `yaml:"logo"` - UserPrefix string `yaml:"user_prefix"` + // Logo 应用 Logo 字符 + Logo string `yaml:"logo"` + // UserIcon 用户输入提示符 + UserIcon string `yaml:"user_icon"` } var ( - defaultCfg = ProjectConfig{ + // defaultCfg 默认配置值,当配置文件不存在或字段为空时使用 + defaultCfg = Config{ Streaming: StreamingConfig{ LineDelayMs: 1000, LastLineDelayMs: 600, }, Markdown: MarkdownConfig{ - GlamourStyle: "dark", - WrapWidth: 0, + Theme: "dark", + LineWidth: 0, }, UI: UIConfig{ - Logo: "🦐", - UserPrefix: "👀 ", + Logo: "🦐", + UserIcon: "👀 ", }, } - projCfg *ProjectConfig - projCfgLock sync.RWMutex + cfg *Config // 已合并的配置(用户配置 + 项目配置) + cfgLock sync.RWMutex // 配置读写锁 + userCfg *Config // 用户配置文件解析结果 + userLock sync.RWMutex // 用户配置读写锁 ) -func LoadProjectConfig() error { - projCfgLock.Lock() - defer projCfgLock.Unlock() +// 用户配置文件路径常量 +const ( + userConfigDir = ".config/hxclaw" // 用户配置目录(相对于用户家目录) + userConfigFile = "config.yml" // 用户配置文件名 +) - cfgPath := getConfigPath() - if cfgPath == "" { - projCfg = &defaultCfg +// 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) { - projCfg = &defaultCfg - return nil + return &defaultCfg } - 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 + + var projCfg Config + if err := yaml.Unmarshal(data, &projCfg); err != nil { + return &defaultCfg + } + + return &projCfg } -func getConfigPath() string { +// 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") -} +} \ No newline at end of file diff --git a/cmd/hxclaw/internal/markdown.go b/cmd/hxclaw/internal/markdown.go index b091e5a..f9c4bef 100644 --- a/cmd/hxclaw/internal/markdown.go +++ b/cmd/hxclaw/internal/markdown.go @@ -1,3 +1,5 @@ +// Package internal 包含 hxclaw 的内部工具模块 +// 提供配置管理、Markdown 渲染、输入读取等功能 package internal import ( @@ -9,6 +11,8 @@ import ( "github.com/charmbracelet/x/term" ) +// RenderMarkdown 将 Markdown 文本渲染为终端友好的格式 +// 支持通过配置或环境变量指定渲染主题和换行宽度 func RenderMarkdown(md string) string { if md == "" { return "" @@ -67,8 +71,8 @@ func RenderParagraph(text string) string { func getStyle() string { if cfg := GetProjectConfig(); cfg != nil { - if cfg.Markdown.GlamourStyle != "" { - return cfg.Markdown.GlamourStyle + if cfg.Markdown.Theme != "" { + return cfg.Markdown.Theme } } if s := os.Getenv("GLAMOUR_STYLE"); s != "" { @@ -79,10 +83,10 @@ func getStyle() string { func getWrapWidth() int { if cfg := GetProjectConfig(); cfg != nil { - if cfg.Markdown.WrapWidth > 0 { - return cfg.Markdown.WrapWidth + if cfg.Markdown.LineWidth > 0 { + return cfg.Markdown.LineWidth } - if cfg.Markdown.WrapWidth < 0 { + if cfg.Markdown.LineWidth < 0 { return 0 } } diff --git a/cmd/hxclaw/internal/spinner.go b/cmd/hxclaw/internal/spinner.go index aa26c90..a51ab81 100644 --- a/cmd/hxclaw/internal/spinner.go +++ b/cmd/hxclaw/internal/spinner.go @@ -1,3 +1,5 @@ +// Package internal 包含 hxclaw 的内部工具模块 +// 提供配置管理、Markdown 渲染、输入读取等功能 package internal import ( @@ -9,22 +11,26 @@ import ( "charm.land/lipgloss/v2" ) +// SpinnerState 表示加载动画的状态 type SpinnerState int const ( - StateThinking SpinnerState = iota - StateAnswering - StateDone + StateThinking SpinnerState = iota // 思考中状态 + StateAnswering // 回答中状态 + StateDone // 完成状态 ) +// Spinner 加载动画组件,用于显示思考状态 type Spinner struct { - text string - state SpinnerState - spinner spinner.Model - stopCh chan struct{} - doneCh chan struct{} + text string // 显示的文本内容 + state SpinnerState // 当前状态 + spinner spinner.Model // 底层动画模型 + stopCh chan struct{} // 停止信号通道 + doneCh chan struct{} // 完成信号通道 } +// NewSpinner 创建一个新的加载动画实例 +// text: 初始显示的文本内容 func NewSpinner(text string) *Spinner { s := spinner.New( spinner.WithSpinner(spinner.MiniDot), diff --git a/cmd/hxclaw/main.go b/cmd/hxclaw/main.go index c6c5f12..53ecf27 100644 --- a/cmd/hxclaw/main.go +++ b/cmd/hxclaw/main.go @@ -1,3 +1,5 @@ +// Package main 是 hxclaw 应用程序的入口包 +// 提供交互式 CLI 界面和流式输出功能 package main import ( @@ -18,41 +20,53 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +// Logo 应用 Logo 字符常量 const Logo = "🦐" +// main 程序入口函数 +// 负责初始化配置、创建 Provider、启动 Agent Loop 和交互式界面 func main() { + // 加载 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() if err != nil { fmt.Fprintf(os.Stderr, "错误:加载配置失败: %v\n", err) os.Exit(1) } + // 配置日志系统 logger.ConfigureFromEnv() + // 创建 AI Provider(支持 OpenAI、Claude 等) provider, modelID, err := providers.CreateProvider(cfg) if err != nil { fmt.Fprintf(os.Stderr, "错误:创建 Provider 失败: %v\n", err) os.Exit(1) } + // 如果命令行指定了模型 ID,覆盖配置文件中的默认模型 if modelID != "" { cfg.Agents.Defaults.ModelName = modelID } + // 创建消息总线,用于组件间通信 msgBus := bus.NewMessageBus() defer msgBus.Close() + // 创建 Agent Loop,处理用户交互和 AI 请求 agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) defer agentLoop.Close() + // 获取启动信息并打印 startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("hxclaw", "HxClaw 已初始化", map[string]any{ @@ -61,12 +75,15 @@ func main() { "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 := internal.GetProjectConfig().UI.UserPrefix + prompt := internal.GetProjectConfig().UI.UserIcon rl, err := internal.NewReadline(prompt) if err != nil { @@ -105,7 +122,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { reader := internal.NewSimpleReader() for { - fmt.Print(internal.GetProjectConfig().UI.UserPrefix) + fmt.Print(internal.GetProjectConfig().UI.UserIcon) line, err := reader.ReadString() if err != nil { if err == internal.ErrEOF { @@ -130,30 +147,42 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } } -// runWithStreaming 使用 ProcessDirect 处理请求,支持工具调用和结果显示 +// runWithStreaming 使用 ProcessDirect 处理请求并以模拟流式方式输出结果 +// 1. 显示加载动画表示 AI 正在思考 +// 2. 调用 Agent 处理用户输入 +// 3. 渲染 Markdown 输出并按行延迟显示 +// 4. 打印处理耗时 func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) { startTime := time.Now() + // 创建并启动加载动画 spinner := internal.NewSpinner("思考中...") spinner.Start() + // 调用 AI 处理用户输入 resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey) + // 停止加载动画 spinner.Stop() + // 处理错误 if err != nil { fmt.Printf("错误: %v\n", err) return } + // 渲染 Markdown 并输出 rendered := internal.RenderMarkdown(resp) clearSpinnerLine() outputLineByLine(rendered) + // 打印处理耗时 elapsed := time.Since(startTime) printElapsed(elapsed) } +// clearSpinnerLine 清除 spinner 行 +// 使用终端控制字符清除当前行并移动到行首 func clearSpinnerLine() { output := termenv.DefaultOutput() output.ClearLine() @@ -161,6 +190,9 @@ func clearSpinnerLine() { os.Stdout.Sync() } +// outputLineByLine 按行输出文本,模拟打字效果 +// 每行之间根据配置延迟,最后一行延迟时间较长 +// 空行会直接输出不延迟 func outputLineByLine(text string) { if text == "" { return @@ -169,10 +201,12 @@ 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 == "" { lipgloss.Print("\n") @@ -181,6 +215,7 @@ func outputLineByLine(text string) { lipgloss.Print(line + "\n") + // 非最后一行使用普通延迟,最后一行使用较长延迟 if i < totalLines-1 { time.Sleep(lineDelay) } else { @@ -191,11 +226,14 @@ func outputLineByLine(text string) { lipgloss.Print("\n") } +// 输出样式定义 var ( - iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e")) - textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32")) + iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e")) // 图标样式(金色) + textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32")) // 文本样式(深灰) ) +// printElapsed 打印处理耗时信息 +// 格式化输出:小于 60 秒显示秒数,否则显示分钟数 func printElapsed(elapsed time.Duration) { elapsedSec := math.Round(elapsed.Seconds()*10) / 10 elapsedStr := formatDuration(elapsedSec) @@ -205,6 +243,8 @@ func printElapsed(elapsed time.Duration) { fmt.Printf(" %s%s\n\n", icon, text) } +// formatTokens 格式化 token 数量 +// 1000 以上显示为 k 单位(如 1.5k) func formatTokens(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) @@ -212,6 +252,8 @@ func formatTokens(n int) string { return fmt.Sprintf("%d", n) } +// formatDuration 格式化时长字符串 +// 小于 60 秒显示秒数(如 12.5s),否则显示分钟数(如 2.5m) func formatDuration(s float64) string { if s >= 60 { return fmt.Sprintf("%.1fm", s/60) diff --git a/hxclaw b/hxclaw deleted file mode 100755 index 9bedc72..0000000 Binary files a/hxclaw and /dev/null differ diff --git a/project.config.yml b/project.config.yml index 6f42025..f540a31 100644 --- a/project.config.yml +++ b/project.config.yml @@ -1,4 +1,5 @@ # hxclaw 项目配置文件 +# 项目级配置,会覆盖用户配置 # 模拟流式输出配置 streaming: @@ -7,10 +8,10 @@ streaming: # Markdown 渲染配置 markdown: - glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等 - wrap_width: -1 # 自动换行宽度(-1=禁用,0=自动,>0=固定宽度) + theme: light # 渲染主题:dark, light, dracula, tokyo-night 等 + line_width: 100 # 自动换行宽度(0=自动,>0=固定宽度,-1=禁用) # UI 配置 ui: logo: "🦐" - user_prefix: "👀 " + user_icon: "👀 " diff --git a/taolun.md b/taolun.md index 8bc970a..1d4010b 100644 --- a/taolun.md +++ b/taolun.md @@ -326,6 +326,7 @@ func outputLineByLine(text string) { ```yaml # hxclaw 项目配置文件 +# 项目级配置,会覆盖用户配置 # 模拟流式输出配置 streaming: @@ -334,19 +335,21 @@ streaming: # Markdown 渲染配置 markdown: - glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等 - wrap_width: 0 # 自动换行宽度(0=自动获取终端宽度) + theme: dark # 渲染主题:dark, light, dracula, tokyo-night 等 + line_width: 0 # 自动换行宽度(0=自动获取终端宽度) # UI 配置 ui: logo: "🦐" # Logo - user_prefix: "👀 " # 用户输入前缀 + user_icon: "👀 " # 用户输入提示符 ``` #### 配置加载优先级 -1. 环境变量 `HXCLAW_CONFIG` 指定路径 -2. 项目根目录 `project.config.yml` +1. 用户配置 `~/.config/hxclaw/config.yml` +2. 环境变量 `HXCLAW_CONFIG` 指定路径 +3. 项目根目录 `project.config.yml` +4. 代码中的默认值 #### 代码实现