feat: add AI retry settings
This commit is contained in:
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();
|
||||
|
||||
Reference in new issue
Block a user