diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 2832312..62df050 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -98,8 +98,11 @@ + - 测试连通会直接使用当前表单里的端点、Key 和模型,不必先保存。 + 测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。 @@ -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)) diff --git a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs index 48f2954..03722e7 100644 --- a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs +++ b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs @@ -10,6 +10,8 @@ public sealed class AiOpenAiOptions public List 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 PresetModels) + IReadOnlyList PresetModels, + int RetryCount) { - public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty()); + public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty(), 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 NormalizeModelEntries(IEnumerable? values, string? defaultModel = null) { var normalizedDefault = NormalizeModel(defaultModel); diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs index 03eee10..fbac53f 100644 --- a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -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 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 ExecuteWithRetryAsync( + string operationName, + string model, + int retryCount, + Func> 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 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();