v0.0.1: agentu mvp

Add OpenAI-compatible providers, TUI, slash commands, and model switching.
Add local file tools plus optional yolo shell execution.
Document config, usage, and development workflow.
This commit is contained in:
loveuer
2026-06-22 17:28:22 +08:00
commit a431d1ec95
23 changed files with 3526 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const DefaultPath = "~/.agentu/config.yaml"
type Config struct {
Providers map[string]ProviderConfig `yaml:"providers"`
Agent AgentConfig `yaml:"agent"`
}
type ProviderConfig struct {
Name string
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
Models []string `yaml:"models"`
APIKey string `yaml:"api_key"`
Thinking string `yaml:"thinking"`
ThinkingParam string `yaml:"thinking_param"`
ThinkingConfigured bool
}
type AgentConfig struct {
SystemPrompt string `yaml:"system_prompt"`
WorkingDir string `yaml:"working_dir"`
ToolTimeout time.Duration `yaml:"tool_timeout"`
}
type rawConfig struct {
Providers map[string]rawProviderConfig `yaml:"providers"`
Agent struct {
SystemPrompt string `yaml:"system_prompt"`
WorkingDir string `yaml:"working_dir"`
ToolTimeout string `yaml:"tool_timeout"`
} `yaml:"agent"`
}
type rawProviderConfig struct {
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
Models []string `yaml:"models"`
APIKey string `yaml:"api_key"`
Thinking *string `yaml:"thinking"`
ThinkingParam string `yaml:"thinking_param"`
}
func Load(path string) (*Config, error) {
path, err := ResolvePath(path)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
expanded := os.ExpandEnv(string(data))
var raw rawConfig
decoder := yaml.NewDecoder(strings.NewReader(expanded))
decoder.KnownFields(true)
if err := decoder.Decode(&raw); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg := &Config{
Providers: normalizeProviders(raw),
Agent: AgentConfig{
SystemPrompt: raw.Agent.SystemPrompt,
WorkingDir: raw.Agent.WorkingDir,
},
}
if cfg.Agent.WorkingDir == "" {
cfg.Agent.WorkingDir = "."
}
absWorkingDir, err := filepath.Abs(cfg.Agent.WorkingDir)
if err != nil {
return nil, fmt.Errorf("resolve working_dir: %w", err)
}
cfg.Agent.WorkingDir = absWorkingDir
if raw.Agent.ToolTimeout == "" {
cfg.Agent.ToolTimeout = 30 * time.Second
} else {
timeout, err := time.ParseDuration(raw.Agent.ToolTimeout)
if err != nil {
return nil, fmt.Errorf("parse agent.tool_timeout: %w", err)
}
cfg.Agent.ToolTimeout = timeout
}
if cfg.Agent.SystemPrompt == "" {
cfg.Agent.SystemPrompt = "You are agentu, a concise local agent. Answer simple conversation directly. Use tools only when the user's request needs local project context or local command execution."
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func ResolvePath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath
}
if path == "~" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
return home, nil
}
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
return filepath.Join(home, strings.TrimPrefix(path, "~/")), nil
}
return path, nil
}
func (c *Config) Validate() error {
if len(c.Providers) == 0 {
return errors.New("missing required config: providers")
}
for name, provider := range c.Providers {
var missing []string
prefix := "providers." + name
if strings.TrimSpace(provider.BaseURL) == "" {
missing = append(missing, prefix+".base_url")
}
if strings.TrimSpace(provider.Model) == "" {
missing = append(missing, prefix+".model")
}
if strings.TrimSpace(provider.APIKey) == "" {
missing = append(missing, prefix+".api_key")
}
if len(missing) > 0 {
return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
}
if provider.ThinkingConfigured && !IsThinkingLevel(provider.Thinking) {
return fmt.Errorf("%s.thinking must be one of: %s", prefix, strings.Join(ThinkingLevels(), ", "))
}
}
if c.Agent.ToolTimeout <= 0 {
return errors.New("agent.tool_timeout must be greater than zero")
}
return nil
}
func (c *Config) DefaultProviderName() string {
names := c.ProviderNames()
if len(names) == 0 {
return ""
}
return names[0]
}
func (c *Config) ProviderNames() []string {
names := make([]string, 0, len(c.Providers))
for name := range c.Providers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func normalizeProviders(raw rawConfig) map[string]ProviderConfig {
providers := make(map[string]ProviderConfig)
for name, provider := range raw.Providers {
providers[name] = normalizeProvider(name, provider)
}
return providers
}
func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig {
provider := ProviderConfig{
Name: name,
BaseURL: raw.BaseURL,
Model: raw.Model,
Models: append([]string(nil), raw.Models...),
APIKey: raw.APIKey,
ThinkingParam: raw.ThinkingParam,
}
if provider.Model == "" && len(provider.Models) > 0 {
provider.Model = provider.Models[0]
}
if raw.Thinking != nil {
provider.Thinking = strings.ToLower(strings.TrimSpace(*raw.Thinking))
provider.ThinkingConfigured = true
if provider.ThinkingParam == "" {
provider.ThinkingParam = "thinking"
}
}
return provider
}
func ThinkingLevels() []string {
return []string{"none", "middle", "high", "xhigh", "max"}
}
func IsThinkingLevel(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
for _, level := range ThinkingLevels() {
if value == level {
return true
}
}
return false
}