Files
Cyrene_v2/backend/internal/config/config.go
T
Aska a0357a7bbd feat(voice): real TTS fallback chain and whisper-compatible STT
- TTS providers: cloud (OpenAI-compatible /audio/speech, e.g. Bailian
  compatible mode with cosyvoice), local self-hosted HTTP server,
  edge-tts CLI, espeak CLI, silent — ordered by TTS_PROVIDER list,
  skipping providers whose binary/config is missing
- STT: whisper-compatible /audio/transcriptions endpoint (cloud or
  self-hosted faster-whisper), replacing the empty dashscope stub
- config: TTS_CLOUD_URL/KEY/MODEL/VOICE, TTS_LOCAL_URL, STT_API_URL/
  KEY/MODEL; cloud keys fall back to DASHSCOPE_API_KEY
- gateway /voice/tts now returns real audio/mpeg bytes instead of a
  byte count
- tests: cloud/local/whisper round-trips via httptest, chain skip
  behavior, fallback order; kept and adapted prior service tests
2026-08-21 18:28:39 +08:00

323 lines
7.8 KiB
Go

// Package config provides configuration loading with environment variable binding and startup validation.
// All configuration values are loaded from environment variables with sensible defaults.
// Required fields that are missing will cause a fatal error at startup.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration.
// Fields are populated from environment variables during Load().
type Config struct {
// Environment
Env string // "development" | "production" | "test"
LogLevel string // "debug" | "info" | "warn" | "error"
LogFmt string // "text" | "json"
// Server
Port int
Host string
GinMode string // "debug" | "release" | "test"
// Database
DatabaseURL string // Full connection string (takes priority)
PostgresHost string
PostgresPort int
PostgresUser string
PostgresPassword string
PostgresDB string
PostgresSSLMode string
// NATS
NATSUrl string
// Redis
RedisHost string
RedisPort int
// LLM
LLMBaseURL string
LLMAPIKey string
LLMModel string
LLMFallbackModel string
// Embedding
EmbeddingAPIURL string
EmbeddingModel string
EmbeddingAPIKey string
// Voice
DashScopeAPIKey string
TTSProvider string // comma-separated fallback chain: "cloud,local,edge-tts,espeak,silent"
TTSCloudURL string // OpenAI-compatible base URL for cloud TTS (e.g. Bailian compatible mode)
TTSCloudKey string // falls back to DashScopeAPIKey
TTSCloudModel string
TTSCloudVoice string
TTSLocalURL string // self-hosted TTS POST endpoint
ASRProvider string // legacy label
STTApiURL string // Whisper-compatible base URL (cloud or self-hosted)
STTApiKey string // falls back to DashScopeAPIKey
STTModel string
// Auth
JWTSecret string
JWTExpiryHours int
AdminUsername string
AdminPassword string
RegistrationOpen bool
// Internal Service
InternalServiceToken string
// CORS
AllowedOrigins []string
// Background Thinking
EnableBackgroundThinking bool
ThinkIntervalSec int
ThinkSilenceTimeoutSec int
// Platform Bridge
PlatformBridgeEnabled bool
PlatformChannels string
// IoT
IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none"
// IoT Debug Service URL (legacy, kept for v1 compat)
IoTServiceURL string
// RAG
PersonaDir string
KnowledgeDir string
DataDir string
// Search
SearXNGURL string
// Host Exec
HostExecBackend string // "wsl" | "docker" | "direct" | ""
// Caddy (production)
Domain string
ACMEEmail string
// Ethend (deprecated, kept for compat)
EthendPort int
// WebSocket
WSMaxConnections int
SessionIdleTimeoutMin int
// Timezone
TZ string
}
// DefaultConfig returns a Config populated with safe default values.
func DefaultConfig() *Config {
return &Config{
Env: "development",
LogLevel: "info",
LogFmt: "text",
Port: 8080,
Host: "0.0.0.0",
GinMode: "debug",
PostgresHost: "localhost",
PostgresPort: 5432,
PostgresUser: "cyrene",
PostgresPassword: "cyrene_pass",
PostgresDB: "cyrene_ai",
PostgresSSLMode: "disable",
NATSUrl: "nats://localhost:4222",
RedisHost: "localhost",
RedisPort: 6379,
LLMBaseURL: "https://api.openai.com/v1",
LLMModel: "gpt-4o",
LLMFallbackModel: "gpt-4o-mini",
EmbeddingModel: "text-embedding-3-small",
TTSProvider: "cloud,local,edge-tts,silent",
TTSCloudURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
TTSCloudModel: "cosyvoice-v1",
TTSCloudVoice: "longxiaochun",
ASRProvider: "dashscope",
JWTExpiryHours: 720,
AdminUsername: "admin",
RegistrationOpen: true,
EnableBackgroundThinking: true,
ThinkIntervalSec: 300,
ThinkSilenceTimeoutSec: 120,
IoTProvider: "mock",
PersonaDir: "./internal/persona",
KnowledgeDir: "./data/knowledge",
DataDir: "./data",
HostExecBackend: "",
EthendPort: 9090,
WSMaxConnections: 1000,
SessionIdleTimeoutMin: 30,
TZ: "Asia/Shanghai",
}
}
// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set.
func (c *Config) GetDatabaseURL() string {
if c.DatabaseURL != "" {
return c.DatabaseURL
}
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode)
}
// IsDevelopment returns true when running in development mode.
func (c *Config) IsDevelopment() bool {
return c.Env == "development"
}
// IsProduction returns true when running in production mode.
func (c *Config) IsProduction() bool {
return c.Env == "production"
}
// validate runs all validation checks on the loaded config.
// Returns a list of validation errors.
func (c *Config) validate() []string {
var errs []string
// Required: LLM API Key (only required in production or when not using mock)
if c.LLMAPIKey == "" {
errs = append(errs, "LLM_API_KEY is required")
}
// Required: JWT Secret
if c.JWTSecret == "" {
errs = append(errs, "JWT_SECRET is required")
}
if len(c.JWTSecret) < 32 {
errs = append(errs, "JWT_SECRET must be at least 32 characters")
}
// Required: Admin Password
if c.AdminPassword == "" {
errs = append(errs, "ADMIN_PASSWORD is required")
}
if len(c.AdminPassword) < 8 {
errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters")
}
// Required: Internal Service Token
if c.InternalServiceToken == "" && c.IsProduction() {
errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production")
}
// IoT Provider must be valid
validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true}
if !validIoTProviders[c.IoTProvider] {
errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider))
}
return errs
}
// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing.
func (c *Config) ValidateOrFatal() {
errs := c.validate()
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
fmt.Fprintf(os.Stderr, "=======================================\n")
fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n")
os.Exit(1)
}
}
// --- Helper functions for loading from environment ---
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
i, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal)
return defaultVal
}
return i
}
func getEnvBool(key string, defaultVal bool) bool {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
}
func getEnvSlice(key string, defaultVal []string) []string {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return defaultVal
}
return result
}
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
d, err := time.ParseDuration(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
return d
}