2025-12-25 11:20:56 +08:00
|
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
|
|
2026-01-28 11:19:23 +08:00
|
|
|
|
namespace Service.AI;
|
2025-12-25 11:20:56 +08:00
|
|
|
|
|
|
|
|
|
|
public interface IOpenAiService
|
|
|
|
|
|
{
|
|
|
|
|
|
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
2025-12-26 17:13:57 +08:00
|
|
|
|
Task<string?> ChatAsync(string prompt);
|
2025-12-25 15:40:50 +08:00
|
|
|
|
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
2025-12-26 17:13:57 +08:00
|
|
|
|
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
|
2025-12-25 11:20:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public class OpenAiService(
|
2026-01-18 22:04:56 +08:00
|
|
|
|
IOptions<AiSettings> aiSettings,
|
2025-12-25 11:20:56 +08:00
|
|
|
|
ILogger<OpenAiService> logger
|
|
|
|
|
|
) : IOpenAiService
|
|
|
|
|
|
{
|
|
|
|
|
|
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = aiSettings.Value;
|
2026-01-30 10:41:19 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
|
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
2025-12-25 11:20:56 +08:00
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Model))
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var http = new HttpClient();
|
|
|
|
|
|
http.Timeout = TimeSpan.FromSeconds(15);
|
|
|
|
|
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
|
|
|
|
|
|
|
|
|
|
|
var payload = new
|
|
|
|
|
|
{
|
|
|
|
|
|
model = cfg.Model,
|
|
|
|
|
|
temperature = 0,
|
|
|
|
|
|
messages = new object[]
|
|
|
|
|
|
{
|
|
|
|
|
|
new { role = "system", content = systemPrompt },
|
|
|
|
|
|
new { role = "user", content = userPrompt }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
|
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
|
|
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2025-12-25 11:20:56 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var resp = await http.PostAsync(url, content);
|
|
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
|
|
|
|
{
|
|
|
|
|
|
var err = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var respText = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
|
|
|
|
|
|
using var doc = JsonDocument.Parse(respText);
|
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
|
var contentText = root.GetProperty("choices")[0]
|
|
|
|
|
|
.GetProperty("message")
|
|
|
|
|
|
.GetProperty("content")
|
|
|
|
|
|
.GetString();
|
|
|
|
|
|
|
|
|
|
|
|
return contentText;
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogError(ex, "AI 调用失败");
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-25 15:40:50 +08:00
|
|
|
|
|
2025-12-26 17:13:57 +08:00
|
|
|
|
public async Task<string?> ChatAsync(string prompt)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = aiSettings.Value;
|
2026-01-30 10:41:19 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
|
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
2025-12-26 17:13:57 +08:00
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Model))
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var http = new HttpClient();
|
2026-01-09 14:03:01 +08:00
|
|
|
|
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
2025-12-26 17:13:57 +08:00
|
|
|
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
|
|
|
|
|
|
|
|
|
|
|
var payload = new
|
|
|
|
|
|
{
|
|
|
|
|
|
model = cfg.Model,
|
|
|
|
|
|
temperature = 0,
|
|
|
|
|
|
messages = new object[]
|
|
|
|
|
|
{
|
|
|
|
|
|
new { role = "user", content = prompt }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
|
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
|
|
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2025-12-26 17:13:57 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var resp = await http.PostAsync(url, content);
|
|
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
|
|
|
|
{
|
|
|
|
|
|
var err = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var respText = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
|
|
|
|
|
|
using var doc = JsonDocument.Parse(respText);
|
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
|
var contentText = root.GetProperty("choices")[0]
|
|
|
|
|
|
.GetProperty("message")
|
|
|
|
|
|
.GetProperty("content")
|
|
|
|
|
|
.GetString();
|
|
|
|
|
|
|
|
|
|
|
|
return contentText;
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogError(ex, "AI 调用失败");
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async IAsyncEnumerable<string> ChatStreamAsync(string prompt)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = aiSettings.Value;
|
2026-01-30 10:41:19 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
|
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
2025-12-26 17:13:57 +08:00
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Model))
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
|
|
|
|
|
yield break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var http = new HttpClient();
|
|
|
|
|
|
http.Timeout = TimeSpan.FromMinutes(5);
|
|
|
|
|
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
|
|
|
|
|
|
|
|
|
|
|
var payload = new
|
|
|
|
|
|
{
|
|
|
|
|
|
model = cfg.Model,
|
|
|
|
|
|
temperature = 0,
|
|
|
|
|
|
stream = true,
|
|
|
|
|
|
messages = new object[]
|
|
|
|
|
|
{
|
|
|
|
|
|
new { role = "user", content = prompt }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
|
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
|
|
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2026-01-18 22:25:59 +08:00
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
|
|
|
|
|
request.Content = content;
|
2025-12-26 17:13:57 +08:00
|
|
|
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2025-12-26 17:13:57 +08:00
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
|
|
|
|
{
|
|
|
|
|
|
var err = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var stream = await resp.Content.ReadAsStreamAsync();
|
|
|
|
|
|
using var reader = new StreamReader(stream);
|
|
|
|
|
|
|
|
|
|
|
|
string? line;
|
|
|
|
|
|
while ((line = await reader.ReadLineAsync()) != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var data = line.Substring(6).Trim();
|
|
|
|
|
|
if (data == "[DONE]")
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
using var doc = JsonDocument.Parse(data);
|
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
|
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
var delta = choices[0].GetProperty("delta");
|
|
|
|
|
|
if (delta.TryGetProperty("content", out var contentProp))
|
|
|
|
|
|
{
|
|
|
|
|
|
var contentText = contentProp.GetString();
|
|
|
|
|
|
if (!string.IsNullOrEmpty(contentText))
|
|
|
|
|
|
{
|
|
|
|
|
|
yield return contentText;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 15:40:50 +08:00
|
|
|
|
public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = aiSettings.Value;
|
2026-01-30 10:41:19 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
|
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
2025-12-25 15:40:50 +08:00
|
|
|
|
string.IsNullOrWhiteSpace(cfg.Model))
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
|
|
|
|
|
yield break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var http = new HttpClient();
|
|
|
|
|
|
http.Timeout = TimeSpan.FromMinutes(5);
|
|
|
|
|
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
|
|
|
|
|
|
|
|
|
|
|
var payload = new
|
|
|
|
|
|
{
|
|
|
|
|
|
model = cfg.Model,
|
|
|
|
|
|
temperature = 0,
|
|
|
|
|
|
stream = true,
|
|
|
|
|
|
messages = new object[]
|
|
|
|
|
|
{
|
|
|
|
|
|
new { role = "system", content = systemPrompt },
|
|
|
|
|
|
new { role = "user", content = userPrompt }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
|
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
|
|
|
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2025-12-25 15:40:50 +08:00
|
|
|
|
// 使用 SendAsync 来支持 HttpCompletionOption
|
2026-01-18 22:25:59 +08:00
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
|
|
|
|
|
request.Content = content;
|
2025-12-25 15:40:50 +08:00
|
|
|
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
2026-01-30 10:41:19 +08:00
|
|
|
|
|
2025-12-25 15:40:50 +08:00
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
|
|
|
|
{
|
|
|
|
|
|
var err = await resp.Content.ReadAsStringAsync();
|
|
|
|
|
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
using var stream = await resp.Content.ReadAsStreamAsync();
|
|
|
|
|
|
using var reader = new StreamReader(stream);
|
|
|
|
|
|
|
|
|
|
|
|
string? line;
|
|
|
|
|
|
while ((line = await reader.ReadLineAsync()) != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var data = line.Substring(6).Trim();
|
|
|
|
|
|
if (data == "[DONE]")
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// 解析JSON时不使用try-catch,因为在async iterator中不能使用
|
|
|
|
|
|
using var doc = JsonDocument.Parse(data);
|
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
|
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
var delta = choices[0].GetProperty("delta");
|
|
|
|
|
|
if (delta.TryGetProperty("content", out var contentProp))
|
|
|
|
|
|
{
|
|
|
|
|
|
var contentText = contentProp.GetString();
|
|
|
|
|
|
if (!string.IsNullOrEmpty(contentText))
|
|
|
|
|
|
{
|
|
|
|
|
|
yield return contentText;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-25 11:20:56 +08:00
|
|
|
|
}
|