refactor: 重构AI聊天服务为流式返回,优化OOS路径与仓库初始化逻辑

1. 重构AI聊天接口与实现为流式返回,支持SSE协议
2. 修正BaseRepository的数据库上下文初始化逻辑
3. 更新AutoDotCodeConsumer的OOS存储路径
4. 新增阿里云OSS配置项到appsettings
5. 优化AiChatService的日志与代码注释
This commit is contained in:
glz
2026-06-24 18:25:25 +08:00
parent bf333cd718
commit 3c3453668f
6 changed files with 94 additions and 49 deletions

View File

@ -8,9 +8,10 @@ namespace QYZH.InteractiveMagazine.IService;
public interface IAiChatService public interface IAiChatService
{ {
/// <summary> /// <summary>
/// 发送消息进行AI对话 /// 流式AI对话逐块返回AI生成的内容
/// </summary> /// </summary>
/// <param name="input">聊天输入</param> /// <param name="input">聊天输入</param>
/// <returns>AI返回的提示词内容</returns> /// <param name="cancellationToken">取消令牌</param>
Task<AiChatOutput> ChatAsync(AiChatInput input); /// <returns>AI生成的内容片段流</returns>
IAsyncEnumerable<string> ChatAsync(AiChatInput input, CancellationToken cancellationToken = default);
} }

View File

@ -19,7 +19,8 @@ namespace QYZH.InteractiveMagazine.Repository
private readonly ILogger<BaseRepository<T>> _logger; private readonly ILogger<BaseRepository<T>> _logger;
public BaseRepository(ISqlSugarClient context = null) : base(context) public BaseRepository(ISqlSugarClient context = null) : base(context)
{ {
Context = DbScoped.SugarScope; // 优先使用注入的 context如果没有注入则使用 DbScoped.SugarScope
Context = context ?? DbScoped.SugarScope;
_logger = ServiceContext.GetService<ILogger<BaseRepository<T>>>(); _logger = ServiceContext.GetService<ILogger<BaseRepository<T>>>();
} }

View File

@ -4,6 +4,7 @@ using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -11,7 +12,7 @@ using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.Service; namespace QYZH.InteractiveMagazine.Service;
/// <summary> /// <summary>
/// AI聊天服务实现OpenAI API规范 /// AI聊天服务实现OpenAI API规范,流式返回
/// </summary> /// </summary>
public class AiChatService( public class AiChatService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@ -22,9 +23,9 @@ public class AiChatService(
/// 预设系统提示词 /// 预设系统提示词
/// </summary> /// </summary>
private const string SystemPrompt = """ private const string SystemPrompt = """
# AI / # AI "题目评分/报告提示词"
**/便 AI ** **"题目评分/报告提示词"便 AI **
**** ****
@ -34,8 +35,8 @@ public class AiChatService(
1. **** 1. ****
2. **** ```````` 2. **** ````````
3. **** + + 3. ****"要写得好一点""必须包含三层结构:解释原因 + 表达情绪 + 提出方案"
4. **** 4. ****"仅""必须""至少"
5. **** Markdown 使`#` `##` `###``-`/ 5. **** Markdown 使`#` `##` `###``-`/
--- ---
@ -55,7 +56,7 @@ public class AiChatService(
- `## `/ - `## `/
- `## ` - `## `
3. **** 3. ****
- - "可能""大概""必须""至少"
- 1. 2. 3. - 1. 2. 3.
- -
4. **** 4. ****
@ -66,7 +67,7 @@ public class AiChatService(
- AI 使/ prompt - AI 使/ prompt
- 使 Markdown `#` - 使 Markdown `#`
- - "示例"
--- ---
@ -74,9 +75,11 @@ public class AiChatService(
"""; """;
/// <summary> /// <summary>
/// AI聊天根据用户需求生成提示词 /// 流式AI聊天逐块返回AI生成的内容
/// </summary> /// </summary>
public async Task<AiChatOutput> ChatAsync(AiChatInput input) public async IAsyncEnumerable<string> ChatAsync(
AiChatInput input,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(input.Message)) if (string.IsNullOrWhiteSpace(input.Message))
{ {
@ -95,9 +98,9 @@ public class AiChatService(
throw new BusinessException("AI聊天服务配置不完整请检查 AiChat 配置节", 500); throw new BusinessException("AI聊天服务配置不完整请检查 AiChat 配置节", 500);
} }
logger.LogInformation("开始调用AI聊天服务消息长度{Length}", input.Message.Length); logger.LogInformation("开始调用AI聊天服务(流式),消息长度:{Length}", input.Message.Length);
// 构建 OpenAI Chat Completions 请求体 // 构建 OpenAI Chat Completions 请求体(启用流式返回)
var requestBody = new var requestBody = new
{ {
model = model, model = model,
@ -107,7 +110,8 @@ public class AiChatService(
new { role = "user", content = input.Message } new { role = "user", content = input.Message }
}, },
max_tokens = maxTokens, max_tokens = maxTokens,
temperature = temperature temperature = temperature,
stream = true
}; };
var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
@ -115,40 +119,55 @@ public class AiChatService(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}); });
// 创建 HttpClient 并发起请求 // 创建 HttpClient 并发起请求(使用 ResponseHeadersRead 提前获取响应头以流式读取)
var client = httpClientFactory.CreateClient(); var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(timeoutSeconds > 0 ? timeoutSeconds : 300); client.Timeout = TimeSpan.FromSeconds(timeoutSeconds > 0 ? timeoutSeconds : 300);
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions"); var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
logger.LogError("AI聊天服务调用失败状态码{StatusCode},响应:{Response}", response.StatusCode, responseContent); var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogError("AI聊天服务调用失败状态码{StatusCode},响应:{Response}", response.StatusCode, errorContent);
throw new BusinessException($"AI服务调用失败{response.StatusCode}", 500); throw new BusinessException($"AI服务调用失败{response.StatusCode}", 500);
} }
// 解析 OpenAI 响应 // 流式读取响应
using var doc = JsonDocument.Parse(responseContent); using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
var choices = doc.RootElement.GetProperty("choices"); using var reader = new StreamReader(responseStream);
if (choices.GetArrayLength() == 0)
while (!reader.EndOfStream)
{ {
throw new BusinessException("AI未返回有效内容", 500); cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken);
if (line == null) break;
// 只处理 data: 开头的行
if (!line.StartsWith("data: ")) continue;
var data = line.Substring(6);
if (data == "[DONE]") break;
// 解析 SSE chunk提取 delta.content
using var chunkDoc = JsonDocument.Parse(data);
var choicesEl = chunkDoc.RootElement.GetProperty("choices");
if (choicesEl.GetArrayLength() == 0) continue;
var delta = choicesEl[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var contentEl))
{
var content = contentEl.GetString();
if (!string.IsNullOrEmpty(content))
{
yield return content;
}
}
} }
var content = choices[0] logger.LogInformation("AI聊天服务流式调用完成");
.GetProperty("message")
.GetProperty("content")
.GetString();
logger.LogInformation("AI聊天服务调用成功返回内容长度{Length}", content?.Length ?? 0);
return new AiChatOutput
{
Content = content ?? string.Empty
};
} }
} }

View File

@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Enum; using QYZH.InteractiveMagazine.Models.Enum;
using System.Text.Json;
namespace QYZH.InteractiveMagazine.WebApi.Controllers; namespace QYZH.InteractiveMagazine.WebApi.Controllers;
@ -24,27 +24,39 @@ public class AiChatController : BaseController
} }
/// <summary> /// <summary>
/// AI聊天根据需求生成提示词 /// AI聊天(流式返回),根据需求生成提示词
/// </summary> /// </summary>
/// <param name="input">用户需求消息</param> /// <param name="input">用户需求消息</param>
/// <returns>AI生成的提示词Markdown格式</returns> /// <returns>SSE流逐块返回AI生成的提示词内容</returns>
[HttpPost] [HttpPost]
public async Task<BaseResponse<AiChatOutput>> ChatAsync([FromBody] AiChatInput input) public async Task ChatAsync([FromBody] AiChatInput input)
{ {
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
try try
{ {
var result = await _aiChatService.ChatAsync(input); await foreach (var chunk in _aiChatService.ChatAsync(input, HttpContext.RequestAborted))
return Success(result, "AI聊天成功"); {
var data = JsonSerializer.Serialize(new { content = chunk });
await Response.WriteAsync($"data: {data}\n\n");
await Response.Body.FlushAsync();
}
await Response.WriteAsync("data: [DONE]\n\n");
await Response.Body.FlushAsync();
} }
catch (BusinessException ex) catch (OperationCanceledException)
{ {
_logger.LogWarning(ex, "AI聊天业务异常: {Message}", ex.Message); _logger.LogInformation("客户端断开连接AI聊天流式返回终止");
return BaseResponse<AiChatOutput>.Fail(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "AI聊天系统异常,参数:{Input}", input); _logger.LogError(ex, "AI聊天流式返回异常,参数:{Input}", input);
return BaseResponse<AiChatOutput>.Fail("AI聊天失败请稍后重试"); var errorData = JsonSerializer.Serialize(new { error = "AI聊天失败请稍后重试" });
await Response.WriteAsync($"data: {errorData}\n\n");
await Response.Body.FlushAsync();
} }
} }
} }

View File

@ -235,7 +235,7 @@ public class AutoDotCodeConsumer(IConfiguration configuration,
#region OOS上 #region OOS上
var tempOssDownloadKey = "BookPagePdf/download/" + downloadFileName; var tempOssDownloadKey = "Journal/download/" + downloadFileName;
const int bufferSize = 1 * 1024 * 1024; // 1MB const int bufferSize = 1 * 1024 * 1024; // 1MB

View File

@ -44,5 +44,17 @@
} }
] ]
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"AliyunOSSConfigs": {
"AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf",
"AccessKeySecret": "w29b8wkw6XQVL8GWXgp3ZesgYeDKvf",
"VodBucketName": "outin-5277bbb52bec11f08dbd00163e169e2b.oss-cn-beijing.aliyuncs.com",
"BucketName": "qyzh2025test",
"Region": "beijing",
"RoleArn": "acs:ram::1064745380176636:role/aliyunosstokengeneratorrole",
"DurationSeconds": 3600, //过期时间(秒)
"Endpoint": "oss-cn-beijing.aliyuncs.com",
"ProjectName": "InteractiveMagazine",
"Domain": "https://oss.test.qyzhjy.com/"
}
} }