feat: reorganize admin llm settings

This commit is contained in:
2026-08-22 22:06:01 +08:00
parent 78c42eff53
commit f8f0b3c4fd
17 changed files with 1019 additions and 432 deletions
@@ -0,0 +1,60 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public static class AppConfigDefaults
{
public static readonly IReadOnlyDictionary<string, string> Values =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["brand.app_name"] = "记之",
["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱",
["brand.logo_url"] = "",
["llm.protocol"] = "responses",
["llm.base_url"] = "https://api.openai.com/v1",
["llm.model"] = "gpt-4o-mini",
["llm.max_tokens"] = "1024",
["llm.temperature"] = "0.7",
["limit.daily_ai_messages"] = "200",
["limit.daily_ai_messages_per_user"] = "50",
["limit.max_monthly_budget"] = "99999999",
["feature.ocr_enabled"] = "true",
["feature.voice_enabled"] = "true",
["feature.ai_auto_book"] = "true",
["feature.sticker_enabled"] = "true",
["feature.screenshot_bookkeeping_enabled"] = "true",
["permission.default.ai_enabled"] = "true",
["quota.default_ai_chat_limit"] = "50",
["quota.default_ai_chat_period"] = "day",
["system.default_ledger_name"] = "日常账本",
["system.max_ledgers_per_user"] = "10",
};
public static async Task<int> EnsureAsync(
AppDbContext db,
CancellationToken ct = default)
{
var existing = await db.AppConfigs
.Select(config => config.Key)
.ToListAsync(ct);
var keys = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
var added = 0;
foreach (var (key, value) in Values)
{
if (keys.Contains(key)) continue;
db.AppConfigs.Add(new AppConfig
{
Key = key,
Value = value,
Version = 1,
UpdatedAt = now,
});
added++;
}
if (added > 0) await db.SaveChangesAsync(ct);
return added;
}
}
@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text;
namespace MiaoJiZhang.Api.Services;
public sealed class LlmSecretProtector(IConfiguration configuration)
{
public const string ConfigKey = "llm.api_key_encrypted";
private const string Prefix = "v1";
public string Protect(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("API Key 不能为空", nameof(value));
var key = ReadEncryptionKey();
var nonce = RandomNumberGenerator.GetBytes(12);
var plaintext = Encoding.UTF8.GetBytes(value.Trim());
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return string.Join(':', Prefix,
Convert.ToBase64String(nonce),
Convert.ToBase64String(ciphertext),
Convert.ToBase64String(tag));
}
public string Unprotect(string protectedValue)
{
var parts = protectedValue.Split(':');
if (parts.Length != 4 || parts[0] != Prefix)
throw new CryptographicException("不支持的密钥密文格式");
var nonce = Convert.FromBase64String(parts[1]);
var ciphertext = Convert.FromBase64String(parts[2]);
var tag = Convert.FromBase64String(parts[3]);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(ReadEncryptionKey(), tag.Length);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
public bool TryUnprotect(string? protectedValue, out string value)
{
value = "";
if (string.IsNullOrWhiteSpace(protectedValue)) return false;
try
{
value = Unprotect(protectedValue);
return !string.IsNullOrWhiteSpace(value);
}
catch (Exception exception) when (
exception is ArgumentException or FormatException or
CryptographicException or InvalidOperationException)
{
return false;
}
}
public static string Mask(string value)
{
if (string.IsNullOrEmpty(value)) return "";
var suffixLength = Math.Min(4, value.Length);
return $"••••{value[^suffixLength..]}";
}
private byte[] ReadEncryptionKey()
{
var raw = configuration["Secrets:EncryptionKey"];
byte[]? key = null;
try { key = string.IsNullOrWhiteSpace(raw) ? null : Convert.FromBase64String(raw); }
catch (FormatException) { }
if (key?.Length != 32)
throw new InvalidOperationException(
"请通过 Secrets__EncryptionKey 配置 base64 编码的 32 字节密钥后再保存 API Key");
return key;
}
}
@@ -13,22 +13,26 @@ namespace MiaoJiZhang.Api.Services;
public partial class OpenAiVisionClient : ILlmClient
{
private readonly IServiceScopeFactory _sf;
private readonly HttpClient _http;
private readonly ILogger<OpenAiVisionClient> _logger;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private readonly HttpClient _http;
private readonly ILogger<OpenAiVisionClient> _logger;
private readonly LlmSecretProtector _secretProtector;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private double _temperature = 0.7;
private DateTime _last = DateTime.MinValue;
private static readonly object _lk = new();
public OpenAiVisionClient(
IServiceScopeFactory sf,
IHttpClientFactory hf,
ILogger<OpenAiVisionClient> logger)
IServiceScopeFactory sf,
IHttpClientFactory hf,
LlmSecretProtector secretProtector,
ILogger<OpenAiVisionClient> logger)
{
_sf = sf;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_logger = logger;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_secretProtector = secretProtector;
_logger = logger;
}
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
@@ -679,14 +683,22 @@ public partial class OpenAiVisionClient : ILlmClient
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
{
Load();
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
if (_protocol != "responses")
return (false, "AI Agent 记账要求使用 Responses 协议");
try
{
var tool = new AgentToolDefinition(
Load();
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
try
{
if (_protocol != "responses")
{
var reply = await L(
"你正在执行连接测试,只回复 OK。",
"测试连接",
ct);
return string.IsNullOrWhiteSpace(reply)
? (false, "模型没有返回内容")
: (true, null);
}
var tool = new AgentToolDefinition(
"diagnostic_echo",
"连接测试时必须调用的无副作用工具",
JsonSerializer.Deserialize<JsonElement>(
@@ -716,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient
var msgs = new List<object>();
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
}
await SA(BuildBody(msgs, _maxTokens, _temperature), onToken, ct);
}
public void InvalidateConfiguration()
{
lock (_lk) _last = DateTime.MinValue;
}
void Load()
{
@@ -728,23 +745,36 @@ public partial class OpenAiVisionClient : ILlmClient
try
{
using var scope = _sf.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
"").TrimEnd('/');
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
config.GetValueOrDefault("llm.protocol", "chat_completions");
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
_apiKey = _secretProtector.TryUnprotect(
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
out var protectedApiKey)
? protectedApiKey
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
config.GetValueOrDefault("llm.base_url") ??
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
"https://api.openai.com/v1").TrimEnd('/');
_model = config.GetValueOrDefault("llm.model") ??
Environment.GetEnvironmentVariable("LLM_MODEL") ??
"gpt-4o-mini";
_protocol = config.GetValueOrDefault("llm.protocol") ??
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
"responses";
_maxTokens = int.TryParse(
config.GetValueOrDefault("llm.max_tokens"),
out var maxTokens)
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_last = DateTime.UtcNow;
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_temperature = double.TryParse(
config.GetValueOrDefault("llm.temperature"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var temperature)
? Math.Clamp(temperature, 0, 2)
: 0.7;
_last = DateTime.UtcNow;
}
catch (Exception exception)
{
@@ -754,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
}
async Task<string?> L(string sys, string user, CancellationToken ct)
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, _temperature), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };