// Package service 负责管理用户的本地 AI 配置(API Key、模型选择等)。 // // 所有的配置都持久化存储在 settings.db 中。 // 这里的配置与 config.yaml 的职责有本质区别: // - config.yaml 是"应用的公共回退配置"(开发者提供); // - settings.db 是"用户自己配置的 API Key"(数据主权归用户)。 package service import ( "fmt" "strconv" "AI-Expert-Sidebar/internal/config" "AI-Expert-Sidebar/internal/crypto" "AI-Expert-Sidebar/internal/database" "AI-Expert-Sidebar/internal/models" ) // SettingsDTO 是在前后端之间传递配置信息的"数据传输对象"。 // 它将散落在 settings.db KV 表里的各条记录聚合为一个结构体,方便前端 React 渲染表单。 type SettingsDTO struct { AIProvider string `json:"ai_provider"` BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Model string `json:"model"` SystemPrompt string `json:"system_prompt"` MaxTokens int `json:"max_tokens"` // UsePublicKey: true 表示用户不想用自己的 Key,而是使用软件内置的 Key。 UsePublicKey bool `json:"use_public_key"` } // GetSettings 从 settings.db 读取所有 AI 配置,并组装为 DTO 返回给前端。 // 如果发现 api_key 是被加密过的,会在此处进行解密。 func GetSettings() *SettingsDTO { sdb := database.GetSettings() if sdb == nil { return defaultDTO() } // 将 KV 表一次性读取到 map 中 var rows []models.AppSetting sdb.Find(&rows) m := make(map[string]string, len(rows)) for _, r := range rows { m[r.Key] = r.Value } // 尝试解密 API Key,若尚未配置或密文损坏,则返回空字符串 apiKey, _ := crypto.DecryptAPIKey(m["api_key_encrypted"]) maxTokens := 1024 if mt, err := strconv.Atoi(m["max_tokens"]); err == nil && mt > 0 { maxTokens = mt } return &SettingsDTO{ AIProvider: strOr(m["ai_provider"], "deepseek"), // 默认厂商为 deepseek BaseURL: m["base_url"], APIKey: apiKey, // 传递给前端的是明文 Model: strOr(m["model"], "deepseek-chat"), SystemPrompt: m["system_prompt"], MaxTokens: maxTokens, UsePublicKey: m["use_public_key"] != "false", // 默认 true } } // SaveSettings 接收前端传来的 DTO 并持久化到 settings.db。 // // # 数据安全 // 为了保护用户的 API Key,APIKey 字段在入库前会被强制进行 AES-256 加密, // 所以数据库里只会写入 api_key_encrypted。 func SaveSettings(dto SettingsDTO) error { sdb := database.GetSettings() if sdb == nil { return fmt.Errorf("settings DB not ready") } // 定义 UPSERT 闭包:使用 GORM 的原生 SQL,如果 key 已存在则更新 value,不存在则插入 upsert := func(k, v string) { sdb.Exec("INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", k, v) } upsert("ai_provider", dto.AIProvider) upsert("base_url", dto.BaseURL) upsert("model", dto.Model) upsert("system_prompt", dto.SystemPrompt) upsert("max_tokens", fmt.Sprintf("%d", dto.MaxTokens)) usePublic := "true" if !dto.UsePublicKey { usePublic = "false" } upsert("use_public_key", usePublic) // 如果用户选择使用私有 Key,并且确实输入了 Key,则对其加密后存储。 // 如果前端传来了空字符串(可能是用户想清空),这里不会主动覆盖旧密文, // 需要增强逻辑:本系统目前只有覆盖更新,不提供独立删除 Key 按钮, // 留空即代表不更新原本缓存的 Key 密文。 if !dto.UsePublicKey && dto.APIKey != "" { enc, err := crypto.EncryptAPIKey(dto.APIKey) if err != nil { return err } upsert("api_key_encrypted", enc) } return nil } // ResolveAIConfig 是 AI 调用的前置拦截器,决定最终到底使用哪个 API Key 和地址。 // // # 动态回退策略 // 1. 如果用户勾选了 "使用公共线路" 或是没填过自己的 API Key: // 直接短路,返回 config.yaml 里打包的公共 Key 和端点; // 2. 如果用户提供了私有 Key,优先使用用户的 Key; // 3. 对 BaseURL 进行缺省补全:如果选择了特定厂商(如 deepseek)但没填 URL, // 代码会自动填充标准官方 URL,极大简化了用户的配置门槛。 func ResolveAIConfig() AICallConfig { // 默认配置回滚:来源于 config.yaml base := AICallConfig{ BaseURL: "https://api.deepseek.com/chat/completions", APIKey: config.Global.DeepSeek.APIKey, Model: config.Global.DeepSeek.Model, MaxTokens: config.Global.DeepSeek.MaxTokens, } dto := GetSettings() if dto == nil { return base } // 无论使用什么 Key,用户的自定义系统提示词均生效 base.SystemPrompt = dto.SystemPrompt // 若用户指定使用公共线路,或用户的私钥实际上为空,触发降级 if dto.UsePublicKey || dto.APIKey == "" { return base } providerURL := dto.BaseURL if providerURL == "" { // 常见厂商的默认 API 路由表补全 switch dto.AIProvider { case "deepseek": providerURL = "https://api.deepseek.com/chat/completions" case "openai": providerURL = "https://api.openai.com/v1/chat/completions" case "grok": providerURL = "https://api.x.ai/v1/chat/completions" } } maxTok := dto.MaxTokens if maxTok <= 0 { maxTok = 1024 } // 返回拼接好的终端可直接消费的 AI 配置 return AICallConfig{ BaseURL: strOr(providerURL, base.BaseURL), APIKey: dto.APIKey, Model: strOr(dto.Model, base.Model), MaxTokens: maxTok, SystemPrompt: dto.SystemPrompt, } } // strOr 是一个简单的 fallback 辅助函数。 func strOr(v, def string) string { if v == "" { return def } return v } func defaultDTO() *SettingsDTO { return &SettingsDTO{AIProvider: "deepseek", Model: "deepseek-chat", MaxTokens: 1024, UsePublicKey: true} }