feat: add AI retry settings

This commit is contained in:
meoacgx committed 2026-03-10 05:30:26 +08:00
1 parent 47b15fae3d
commit 89a66fd243
3 files changed
+214 -55

No files matched your search

@@ -98,8 +98,11 @@
<MudTextField @bind-Value="aiPresetModelsText" Label="预设模型(每行一个)" Variant="Variant.Outlined" Class="mt-3"
Lines="4"
HelperText="例如:gpt-4o-mini、qwen-vl-plus;任务编辑器会直接复用这里的预设。" />
<MudNumericField @bind-Value="aiRetryCount" Label="AI 失败重试次数" Variant="Variant.Outlined" Class="mt-3"
Min="0" Max="5"
HelperText="0 表示不重试;实际最多请求次数 = 1 + 重试次数。建议 2。" />
<MudText Typo="Typo.caption" Class="mt-2 mud-text-secondary">
测试连通会直接使用当前表单里的端点、Key 和模型,不必先保存。
测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。
</MudText>
</MudCardContent>
<MudCardActions>
@@ -331,6 +334,7 @@
private string aiApiKey = "";
private string aiDefaultModel = "";
private string aiPresetModelsText = "";
private int aiRetryCount = 2;
private bool aiTesting = false;
private sealed record TimeZoneChoice(string Id, string Label);
@@ -383,6 +387,7 @@
aiApiKey = Configuration["AI:OpenAI:ApiKey"] ?? "";
aiDefaultModel = Configuration["AI:OpenAI:DefaultModel"] ?? "";
aiPresetModelsText = string.Join(System.Environment.NewLine, Configuration.GetSection("AI:OpenAI:PresetModels").GetChildren().Select(x => x.Value).Where(x => !string.IsNullOrWhiteSpace(x)));
aiRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(Configuration.GetValue("AI:OpenAI:RetryCount", 2));
}
private async Task SaveApiConfig()
@@ -484,12 +489,19 @@
private async Task SaveAiConfig()
{
if (aiRetryCount < 0 || aiRetryCount > 5)
{
Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error);
return;
}
try
{
var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint);
var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey);
var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel);
var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel);
var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount);
var root = await LoadOrCreateLocalConfigAsync();
var ai = EnsureObject(root, "AI");
@@ -498,6 +510,7 @@
openAi["Endpoint"] = normalizedEndpoint ?? string.Empty;
openAi["ApiKey"] = normalizedApiKey ?? string.Empty;
openAi["DefaultModel"] = normalizedDefaultModel ?? string.Empty;
openAi["RetryCount"] = normalizedRetryCount;
var modelsArray = new System.Text.Json.Nodes.JsonArray();
foreach (var model in presetModels)
@@ -510,6 +523,7 @@
aiApiKey = normalizedApiKey ?? string.Empty;
aiDefaultModel = normalizedDefaultModel ?? string.Empty;
aiPresetModelsText = string.Join(System.Environment.NewLine, presetModels);
aiRetryCount = normalizedRetryCount;
Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success);
StateHasChanged();
@@ -525,6 +539,12 @@
if (aiTesting)
return;
if (aiRetryCount < 0 || aiRetryCount > 5)
{
Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error);
return;
}
aiTesting = true;
try
{
@@ -532,7 +552,8 @@
var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey);
var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel);
var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel);
var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels);
var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount);
var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels, normalizedRetryCount);
var testModel = ResolveAiTestModel(snapshot);
if (!snapshot.TryValidateForTask(testModel, out var validateError))
@@ -10,6 +10,8 @@ public sealed class AiOpenAiOptions
public List<string> PresetModels { get; set; } = new();
public int RetryCount { get; set; } = 2;
public AiOpenAiSettingsSnapshot ToSnapshot()
{
return AiOpenAiSettingsSnapshot.From(this);
@@ -20,9 +22,10 @@ public sealed record AiOpenAiSettingsSnapshot(
string? Endpoint,
string? ApiKey,
string? DefaultModel,
IReadOnlyList<string> PresetModels)
IReadOnlyList<string> PresetModels,
int RetryCount)
{
public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty<string>());
public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty<string>(), 2);
public string CompletionsPath => "/chat/completions";
@@ -115,7 +118,8 @@ public sealed record AiOpenAiSettingsSnapshot(
NormalizeEndpoint(options.Endpoint),
NormalizeApiKey(options.ApiKey),
defaultModel,
presetModels);
presetModels,
NormalizeRetryCount(options.RetryCount));
}
public static string? NormalizeEndpoint(string? value)
@@ -160,6 +164,14 @@ public sealed record AiOpenAiSettingsSnapshot(
return model.Length == 0 ? null : model;
}
public static int NormalizeRetryCount(int value)
{
if (value < 0)
return 0;
return value > 5 ? 5 : value;
}
public static List<string> NormalizeModelEntries(IEnumerable<string>? values, string? defaultModel = null)
{
var normalizedDefault = NormalizeModel(defaultModel);
@@ -82,46 +82,16 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService
try
{
var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken);
var json = ExtractJsonObject(content);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var mode = root.TryGetProperty("mode", out var modeElement)
? (modeElement.GetString() ?? string.Empty).Trim()
: string.Empty;
var reason = root.TryGetProperty("reason", out var reasonElement)
? reasonElement.GetString()
: null;
if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("button_index", out var buttonIndexElement)
|| buttonIndexElement.ValueKind != JsonValueKind.Number
|| !buttonIndexElement.TryGetInt32(out var buttonIndex))
{
return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了点击按钮模式,但缺少有效的 button_index");
}
return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null);
}
if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase))
{
var replyText = root.TryGetProperty("reply_text", out var replyElement)
? (replyElement.GetString() ?? string.Empty).Trim()
: string.Empty;
if (replyText.Length == 0)
return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了 reply_text 模式,但 reply_text 为空");
return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null);
}
return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 返回了未知模式:{mode}");
return await ExecuteWithRetryAsync(
operationName: "验证决策",
model: model,
retryCount: settings.RetryCount,
action: ct => ChooseActionCoreAsync(settings, model, systemPrompt, userContent, ct),
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse AI choice response");
_logger.LogWarning(ex, "Failed to parse AI choice response after retries");
return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 响应解析失败:{ex.Message}");
}
}
@@ -164,16 +134,24 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService
try
{
var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken);
var normalized = StripMarkdownFence(content).Trim();
if (normalized.Length == 0)
return new TelegramPanelAiReplyTextResult(false, null, "AI 返回了空回复");
return await ExecuteWithRetryAsync(
operationName: "文本作答",
model: model,
retryCount: settings.RetryCount,
action: async ct =>
{
var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, ct);
var normalized = StripMarkdownFence(content).Trim();
if (normalized.Length == 0)
throw new InvalidOperationException("AI 返回了空回复");
return new TelegramPanelAiReplyTextResult(true, normalized, null);
return new TelegramPanelAiReplyTextResult(true, normalized, null);
},
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to generate AI reply text");
_logger.LogWarning(ex, "Failed to generate AI reply text after retries");
return new TelegramPanelAiReplyTextResult(false, null, $"AI 请求失败:{ex.Message}");
}
}
@@ -190,12 +168,17 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService
try
{
var content = await SendChatCompletionAsync(
settings,
model,
"你是 AI 连通性测试助手。请只回复 pong。",
"ping",
cancellationToken);
var content = await ExecuteWithRetryAsync(
operationName: "连通测试",
model: model,
retryCount: settings.RetryCount,
action: ct => SendChatCompletionAsync(
settings,
model,
"你是 AI 连通性测试助手。请只回复 pong。",
"ping",
ct),
cancellationToken: cancellationToken);
var normalized = StripMarkdownFence(content).Trim();
if (normalized.Length == 0)
@@ -205,11 +188,97 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI connectivity test failed");
_logger.LogWarning(ex, "AI connectivity test failed after retries");
return (false, model, null, ex.Message);
}
}
private async Task<TelegramPanelAiChooseActionResult> ChooseActionCoreAsync(
AiOpenAiSettingsSnapshot settings,
string model,
string systemPrompt,
object userContent,
CancellationToken cancellationToken)
{
var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken);
var json = ExtractJsonObject(content);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var mode = root.TryGetProperty("mode", out var modeElement)
? (modeElement.GetString() ?? string.Empty).Trim()
: string.Empty;
var reason = root.TryGetProperty("reason", out var reasonElement)
? reasonElement.GetString()
: null;
if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("button_index", out var buttonIndexElement)
|| buttonIndexElement.ValueKind != JsonValueKind.Number
|| !buttonIndexElement.TryGetInt32(out var buttonIndex))
{
throw new InvalidOperationException("AI 返回了点击按钮模式,但缺少有效的 button_index");
}
return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null);
}
if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase))
{
var replyText = root.TryGetProperty("reply_text", out var replyElement)
? (replyElement.GetString() ?? string.Empty).Trim()
: string.Empty;
if (replyText.Length == 0)
throw new InvalidOperationException("AI 返回了 reply_text 模式,但 reply_text 为空");
return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null);
}
throw new InvalidOperationException($"AI 返回了未知模式:{mode}");
}
private async Task<T> ExecuteWithRetryAsync<T>(
string operationName,
string model,
int retryCount,
Func<CancellationToken, Task<T>> action,
CancellationToken cancellationToken)
{
var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(retryCount);
var maxAttempts = Math.Max(1, normalizedRetryCount + 1);
Exception? lastException = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
return await action(cancellationToken);
}
catch (Exception ex)
{
lastException = ex;
if (attempt >= maxAttempts || !IsRetryableAiFailure(ex, cancellationToken))
break;
var delay = GetRetryDelay(attempt);
_logger.LogWarning(
ex,
"AI {OperationName} 失败,准备重试:第 {NextAttempt}/{MaxAttempts} 次,模型={Model},等待 {DelayMs}ms",
operationName,
attempt + 1,
maxAttempts,
model,
(int)delay.TotalMilliseconds);
if (delay > TimeSpan.Zero)
await Task.Delay(delay, cancellationToken);
}
}
throw lastException ?? new InvalidOperationException($"AI {operationName} 失败");
}
private async Task<string> SendChatCompletionAsync(
AiOpenAiSettingsSnapshot settings,
string model,
@@ -266,6 +335,63 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService
}
}
private static bool IsRetryableAiFailure(Exception ex, CancellationToken cancellationToken)
{
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
return false;
if (ex is HttpRequestException)
return true;
if (ex is TaskCanceledException)
return true;
var message = ex.Message ?? string.Empty;
if (message.Contains("端点未配置", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Key 未配置", StringComparison.OrdinalIgnoreCase)
|| message.Contains("未配置 AI", StringComparison.OrdinalIgnoreCase)
|| message.Contains("端点格式无效", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TryGetHttpStatusCode(ex, out var statusCode))
return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500;
return true;
}
private static bool TryGetHttpStatusCode(Exception ex, out int statusCode)
{
const string marker = "AI 请求失败(HTTP ";
var message = ex.Message ?? string.Empty;
var start = message.IndexOf(marker, StringComparison.Ordinal);
if (start < 0)
{
statusCode = 0;
return false;
}
start += marker.Length;
var end = message.IndexOf(')', start);
if (end < 0)
end = message.IndexOf(')', start);
if (end <= start)
{
statusCode = 0;
return false;
}
return int.TryParse(message[start..end], out statusCode);
}
private static TimeSpan GetRetryDelay(int attempt)
{
var delayMs = Math.Min(3000, 800 * Math.Max(1, attempt));
return TimeSpan.FromMilliseconds(delayMs);
}
private static string ExtractJsonObject(string content)
{
var normalized = StripMarkdownFence(content).Trim();