feat: 配置系统重构,添加用户配置和中文注释

This commit is contained in:
2026-04-23 20:11:22 +08:00
parent e070461fe4
commit dd3c8a03e1
10 changed files with 336 additions and 318 deletions

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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),

View File

@@ -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)