diff --git a/QYZH.InteractiveMagazine.IService/IAiChatService.cs b/QYZH.InteractiveMagazine.IService/IAiChatService.cs index d355f8c..10b31a7 100644 --- a/QYZH.InteractiveMagazine.IService/IAiChatService.cs +++ b/QYZH.InteractiveMagazine.IService/IAiChatService.cs @@ -8,9 +8,10 @@ namespace QYZH.InteractiveMagazine.IService; public interface IAiChatService { /// - /// 发送消息进行AI对话 + /// 流式AI对话,逐块返回AI生成的内容 /// /// 聊天输入 - /// AI返回的提示词内容 - Task ChatAsync(AiChatInput input); + /// 取消令牌 + /// AI生成的内容片段流 + IAsyncEnumerable ChatAsync(AiChatInput input, CancellationToken cancellationToken = default); } diff --git a/QYZH.InteractiveMagazine.Repository/BaseRepository.cs b/QYZH.InteractiveMagazine.Repository/BaseRepository.cs index 37bc4c9..d1fb61e 100644 --- a/QYZH.InteractiveMagazine.Repository/BaseRepository.cs +++ b/QYZH.InteractiveMagazine.Repository/BaseRepository.cs @@ -19,7 +19,8 @@ namespace QYZH.InteractiveMagazine.Repository private readonly ILogger> _logger; public BaseRepository(ISqlSugarClient context = null) : base(context) { - Context = DbScoped.SugarScope; + // 优先使用注入的 context,如果没有注入则使用 DbScoped.SugarScope + Context = context ?? DbScoped.SugarScope; _logger = ServiceContext.GetService>>(); } diff --git a/QYZH.InteractiveMagazine.Service/AiChatService.cs b/QYZH.InteractiveMagazine.Service/AiChatService.cs index 2f29ce4..0b33d8c 100644 --- a/QYZH.InteractiveMagazine.Service/AiChatService.cs +++ b/QYZH.InteractiveMagazine.Service/AiChatService.cs @@ -4,6 +4,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -11,7 +12,7 @@ using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.Service; /// -/// AI聊天服务实现(OpenAI API规范) +/// AI聊天服务实现(OpenAI API规范,流式返回) /// public class AiChatService( IHttpClientFactory httpClientFactory, @@ -22,9 +23,9 @@ public class AiChatService( /// 预设系统提示词 /// private const string SystemPrompt = """ - # 提示词:AI 优化“题目评分/报告提示词” + # 提示词:AI 优化"题目评分/报告提示词" - 你是一个专业的提示词优化专家。你的任务是**将用户输入的粗糙、口语化的“题目评分/报告提示词”重构为结构清晰、指令明确、便于 AI 直接执行的标准化提示词**。 + 你是一个专业的提示词优化专家。你的任务是**将用户输入的粗糙、口语化的"题目评分/报告提示词"重构为结构清晰、指令明确、便于 AI 直接执行的标准化提示词**。 用户(题目制作者)当前输入的内容可能包含:题目描述、作答要求、打分规则、评分维度、报告层级、示例等,但组织混乱、表述模糊。你需要**保留所有原始信息,不增删任何实质性规则**,仅优化其结构、逻辑层次和表达精度。 @@ -34,8 +35,8 @@ public class AiChatService( 1. **信息完整**:用户原文中的所有要点、示例、层级区分必须全部保留。 2. **结构清晰**:将内容归入标准模块(如 `任务目标`、`输入格式`、`评分标准`、`报告生成要求`)。 - 3. **指令明确**:将模糊表述(如“要写得好一点”)转化为可操作的判定条件(如“必须包含三层结构:解释原因 + 表达情绪 + 提出方案”)。 - 4. **语言精练**:去除冗余修饰,保留关键限定词(如“仅”“必须”“至少”)。 + 3. **指令明确**:将模糊表述(如"要写得好一点")转化为可操作的判定条件(如"必须包含三层结构:解释原因 + 表达情绪 + 提出方案")。 + 4. **语言精练**:去除冗余修饰,保留关键限定词(如"仅""必须""至少")。 5. **格式统一**:最终输出为 Markdown 格式,使用标题(`#` `##` `###`)、列表(`-`)、加粗/斜体等提升可读性。 --- @@ -55,7 +56,7 @@ public class AiChatService( - `## 报告生成规范`(层级名称、触发条件、评语示例/生成规则) - `## 注意事项`(如有特殊约束) 3. **润色表达**: - - 将“可能”“大概”等不确定词改为明确的“必须”“至少”; + - 将"可能""大概"等不确定词改为明确的"必须""至少"; - 将并列条件用编号列表(1. 2. 3.)拆分; - 将示例单独以代码块或引用块呈现,避免与规则混淆。 4. **保持原意**:不得添加用户未提及的新评分维度或报告层级,也不得删除任何已有规则。 @@ -66,7 +67,7 @@ public class AiChatService( - 只输出优化后的完整提示词(即 AI 可直接使用的评分/报告 prompt),不要输出分析过程。 - 使用 Markdown 语法,标题层级从 `#` 开始。 - - 所有原有示例必须原样保留,并标明“示例”或放在引用块中。 + - 所有原有示例必须原样保留,并标明"示例"或放在引用块中。 --- @@ -74,9 +75,11 @@ public class AiChatService( """; /// - /// AI聊天,根据用户需求生成提示词 + /// 流式AI聊天,逐块返回AI生成的内容 /// - public async Task ChatAsync(AiChatInput input) + public async IAsyncEnumerable ChatAsync( + AiChatInput input, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(input.Message)) { @@ -95,9 +98,9 @@ public class AiChatService( 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 { model = model, @@ -107,7 +110,8 @@ public class AiChatService( new { role = "user", content = input.Message } }, max_tokens = maxTokens, - temperature = temperature + temperature = temperature, + stream = true }; var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions @@ -115,40 +119,55 @@ public class AiChatService( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); - // 创建 HttpClient 并发起请求 + // 创建 HttpClient 并发起请求(使用 ResponseHeadersRead 提前获取响应头以流式读取) var client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(timeoutSeconds > 0 ? timeoutSeconds : 300); var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); - var response = await client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); 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); } - // 解析 OpenAI 响应 - using var doc = JsonDocument.Parse(responseContent); - var choices = doc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() == 0) + // 流式读取响应体 + using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(responseStream); + + 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] - .GetProperty("message") - .GetProperty("content") - .GetString(); - - logger.LogInformation("AI聊天服务调用成功,返回内容长度:{Length}", content?.Length ?? 0); - - return new AiChatOutput - { - Content = content ?? string.Empty - }; + logger.LogInformation("AI聊天服务(流式)调用完成"); } } diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/AiChatController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/AiChatController.cs index 8e1d9c9..baa433f 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/AiChatController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/AiChatController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; using QYZH.InteractiveMagazine.IService; -using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Enum; +using System.Text.Json; namespace QYZH.InteractiveMagazine.WebApi.Controllers; @@ -24,27 +24,39 @@ public class AiChatController : BaseController } /// - /// AI聊天,根据需求生成提示词 + /// AI聊天(流式返回),根据需求生成提示词 /// /// 用户需求消息 - /// AI生成的提示词(Markdown格式) + /// SSE流,逐块返回AI生成的提示词内容 [HttpPost] - public async Task> 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 { - var result = await _aiChatService.ChatAsync(input); - return Success(result, "AI聊天成功"); + await foreach (var chunk in _aiChatService.ChatAsync(input, HttpContext.RequestAborted)) + { + 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); - return BaseResponse.Fail(ex.Message); + _logger.LogInformation("客户端断开连接,AI聊天流式返回终止"); } catch (Exception ex) { - _logger.LogError(ex, "AI聊天系统异常,参数:{Input}", input); - return BaseResponse.Fail("AI聊天失败,请稍后重试"); + _logger.LogError(ex, "AI聊天流式返回异常,参数:{Input}", input); + var errorData = JsonSerializer.Serialize(new { error = "AI聊天失败,请稍后重试" }); + await Response.WriteAsync($"data: {errorData}\n\n"); + await Response.Body.FlushAsync(); } } } diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs index 19f5126..b867997 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs @@ -235,7 +235,7 @@ public class AutoDotCodeConsumer(IConfiguration configuration, #region 把本地文集上传到OOS上 - var tempOssDownloadKey = "BookPagePdf/download/" + downloadFileName; + var tempOssDownloadKey = "Journal/download/" + downloadFileName; const int bufferSize = 1 * 1024 * 1024; // 1MB diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index ca3a55b..61de186 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -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/" + } }