178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
// 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}
|
||
}
|