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();