refactor: 重构AI聊天服务为流式返回,优化OOS路径与仓库初始化逻辑
1. 重构AI聊天接口与实现为流式返回,支持SSE协议 2. 修正BaseRepository的数据库上下文初始化逻辑 3. 更新AutoDotCodeConsumer的OOS存储路径 4. 新增阿里云OSS配置项到appsettings 5. 优化AiChatService的日志与代码注释
This commit is contained in:
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user