DoubaoService.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. using Aspose.Words;
  2. using OASystem.API.OAMethodLib.File;
  3. using System.Net.Http.Headers;
  4. using System.Text;
  5. namespace OASystem.API.OAMethodLib.DoubaoAPI
  6. {
  7. public class DoubaoService : IDoubaoService
  8. {
  9. private readonly IHttpClientFactory _httpClientFactory;
  10. private readonly DoubaoSetting _doubaoSetting;
  11. private readonly ILogger<DoubaoService> _logger;
  12. public DoubaoService(IHttpClientFactory httpClientFactory, DoubaoSetting doubaoSetting, ILogger<DoubaoService> logger)
  13. {
  14. _httpClientFactory = httpClientFactory;
  15. _doubaoSetting = doubaoSetting;
  16. _logger = logger;
  17. }
  18. public async Task<string> CompleteChatAsync(List<DouBaoChatMessage> messages, CompleteChatOptions? options)
  19. {
  20. if (messages == null || !messages.Any())
  21. {
  22. _logger.LogError("消息不能为空 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
  23. return null;
  24. }
  25. if (!messages.Any(x => x.Role == DouBaoRole.system))
  26. {
  27. messages.Insert(0,
  28. new DouBaoChatMessage()
  29. {
  30. Role = DouBaoRole.system,
  31. Content = "你是一个专业的AI助手,请根据用户的问题给出回答"
  32. });
  33. }
  34. options ??= new CompleteChatOptions();
  35. var httpClient = _httpClientFactory.CreateClient("Doubao");
  36. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _doubaoSetting.ApiKey);
  37. httpClient.Timeout = TimeSpan.FromMinutes(10);
  38. var body = new Dictionary<string, object>
  39. {
  40. ["model"] = _doubaoSetting.EndpointId,
  41. ["messages"] = messages.Select(x => new { role = x.Role.ToString(), content = x.Content }).ToArray()
  42. };
  43. if (options.ThinkingOptions.IsThinking)
  44. {
  45. body["reasoning_effort"] = options.ThinkingOptions.ReasoningEffort.ToString().ToLower();
  46. body["thinking"] = new
  47. {
  48. type = "enabled",
  49. };
  50. }
  51. var json = JsonConvert.SerializeObject(body);
  52. var response = await httpClient.PostAsync(
  53. "chat/completions",
  54. new StringContent(json, Encoding.UTF8, "application/json")
  55. );
  56. response.EnsureSuccessStatusCode();
  57. var responseContent = await response.Content.ReadAsStringAsync();
  58. var doubaoResponse = JsonConvert.DeserializeObject<DoubaoResponse>(responseContent);
  59. return doubaoResponse.choices[0].message.content;
  60. }
  61. /// <summary>
  62. /// 多模态实现
  63. /// </summary>
  64. /// <param name="messages"></param>
  65. /// <param name="options"></param>
  66. /// <returns></returns>
  67. public async Task<string> CompleteMultimodalChatAsync(List<DoubaoMultimodalChatMessage> messages, CompleteMultimodalChatOptions? options)
  68. {
  69. // 1. 入参校验
  70. if (messages == null || !messages.Any())
  71. {
  72. _logger.LogError("多模态消息不能为空");
  73. return null;
  74. }
  75. if (string.IsNullOrWhiteSpace(_doubaoSetting.ApiKey) || string.IsNullOrWhiteSpace(_doubaoSetting.EndpointId) || string.IsNullOrWhiteSpace(_doubaoSetting.BaseAddress))
  76. {
  77. _logger.LogError("Doubao多模态配置不完整(ApiKey/EndpointId/BaseAddress)");
  78. return null;
  79. }
  80. options ??= new CompleteMultimodalChatOptions();
  81. var httpClient = _httpClientFactory.CreateClient("Doubao");
  82. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _doubaoSetting.ApiKey);
  83. httpClient.Timeout = TimeSpan.FromMinutes(10); // 超时控制
  84. // 2. 构建请求体(核心:修正type值)
  85. var requestBody = new
  86. {
  87. model = _doubaoSetting.EndpointId,
  88. input = messages.Select(msg => new
  89. {
  90. role = msg.Role.ToLower(), // 接口要求小写
  91. type = "message", // 接口必传的消息类型(固定值)
  92. content = msg.Content.Select(contentItem =>
  93. {
  94. // 自动修正type值,避免传入text/image_url
  95. var correctedType = contentItem.Type.ToLower() switch
  96. {
  97. "text" => "input_text", // 把text修正为input_text
  98. "image_url" => "input_image", // 把image_url修正为input_image
  99. "file" => "input_file", // 文件类型
  100. _ => contentItem.Type // 其他合法值直接使用
  101. };
  102. // 构建内容项(根据类型补充字段)
  103. var contentObj = new Dictionary<string, object>
  104. {
  105. ["type"] = correctedType
  106. };
  107. if (correctedType == "input_text" && !string.IsNullOrWhiteSpace(contentItem.Text))
  108. {
  109. contentObj["text"] = contentItem.Text;
  110. }
  111. else if (correctedType == "input_image" && contentItem.ImageUrl != null && !string.IsNullOrWhiteSpace(contentItem.ImageUrl.Url))
  112. {
  113. // Responses API 要求 image_url 为字符串,而不是对象 { url: ... }
  114. contentObj["image_url"] = contentItem.ImageUrl.Url;
  115. }
  116. else if (correctedType == "input_file" && !string.IsNullOrWhiteSpace(contentItem.FileId))
  117. {
  118. contentObj["file_id"] = contentItem.FileId;
  119. }
  120. return contentObj;
  121. }).ToArray()
  122. }).ToArray(),
  123. temperature = options.Temperature,
  124. thinking = options.ThinkingOptions.IsThinking ? new { type = "enabled" } : null,
  125. reasoning = options.ThinkingOptions.IsThinking ? new { effort = options.ThinkingOptions.ReasoningEffort.ToLower() } : null,
  126. max_output_tokens = options.MaxOutputTokens,
  127. previous_response_id = options.PreviousResponseId,
  128. expire_at = options.ExpireAt
  129. };
  130. try
  131. {
  132. // 3. 发送请求
  133. var json = JsonConvert.SerializeObject(requestBody, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
  134. _logger.LogDebug($"Doubao多模态请求体:{json}");
  135. var response = await httpClient.PostAsync(
  136. "responses",
  137. new StringContent(json, Encoding.UTF8, "application/json")
  138. );
  139. var responseContent = await response.Content.ReadAsStringAsync();
  140. _logger.LogDebug($"Doubao多模态响应:{response.StatusCode} {responseContent}");
  141. if (!response.IsSuccessStatusCode)
  142. {
  143. _logger.LogError($"多模态对话调用失败: {response.StatusCode}, {responseContent}");
  144. return null;
  145. }
  146. // 4. 解析响应(优先兼容 Responses API 的 output 结构)
  147. var parsed = JObject.Parse(responseContent);
  148. // 新结构:output -> type=message -> content -> type=output_text -> text
  149. var outputText = parsed["output"]?
  150. .FirstOrDefault(x => string.Equals(x?["type"]?.ToString(), "message", StringComparison.OrdinalIgnoreCase))?["content"]?
  151. .FirstOrDefault(x => string.Equals(x?["type"]?.ToString(), "output_text", StringComparison.OrdinalIgnoreCase))?["text"]?
  152. .ToString();
  153. if (!string.IsNullOrWhiteSpace(outputText))
  154. {
  155. return outputText;
  156. }
  157. var doubaoResponse = JsonConvert.DeserializeObject<DoubaoMultimodalResponse>(responseContent);
  158. var textContent = doubaoResponse?.Choices?.FirstOrDefault()
  159. ?.Message?.Content?
  160. .FirstOrDefault(c =>
  161. string.Equals(c.Type, "input_text", StringComparison.OrdinalIgnoreCase) ||
  162. string.Equals(c.Type, "output_text", StringComparison.OrdinalIgnoreCase))?
  163. .Text;
  164. if (string.IsNullOrWhiteSpace(textContent))
  165. {
  166. _logger.LogError("Doubao多模态响应无有效结果");
  167. return null;
  168. }
  169. return textContent;
  170. }
  171. catch (TaskCanceledException ex)
  172. {
  173. _logger.LogError(ex, "Doubao多模态接口调用超时");
  174. return null;
  175. }
  176. catch (Exception ex)
  177. {
  178. _logger.LogError(ex, "Doubao多模态对话调用异常");
  179. return null;
  180. }
  181. }
  182. public async Task<DoubaoFileResponse> UploadFileAsync(Stream fileStream, string fileName, string purpose = "user_data")
  183. {
  184. var httpClient = _httpClientFactory.CreateClient("Doubao");
  185. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _doubaoSetting.ApiKey);
  186. using var content = new MultipartFormDataContent();
  187. // 添加 purpose 参数
  188. content.Add(new StringContent(purpose), "purpose");
  189. // 添加文件流
  190. var fileContent = new StreamContent(fileStream);
  191. fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
  192. content.Add(fileContent, "file", fileName);
  193. var response = await httpClient.PostAsync("files", content);
  194. if (!response.IsSuccessStatusCode)
  195. {
  196. var errorContent = await response.Content.ReadAsStringAsync();
  197. _logger.LogError($"上传文件失败: {response.StatusCode}, {errorContent}");
  198. response.EnsureSuccessStatusCode();
  199. }
  200. var responseContent = await response.Content.ReadAsStringAsync();
  201. return JsonConvert.DeserializeObject<DoubaoFileResponse>(responseContent);
  202. }
  203. public async Task<DoubaoFileListResponse> ListFilesAsync()
  204. {
  205. var httpClient = _httpClientFactory.CreateClient("Doubao");
  206. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _doubaoSetting.ApiKey);
  207. var response = await httpClient.GetAsync("files");
  208. response.EnsureSuccessStatusCode();
  209. var responseContent = await response.Content.ReadAsStringAsync();
  210. return JsonConvert.DeserializeObject<DoubaoFileListResponse>(responseContent);
  211. }
  212. public async Task<bool> DeleteFileAsync(string fileId)
  213. {
  214. var httpClient = _httpClientFactory.CreateClient("Doubao");
  215. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _doubaoSetting.ApiKey);
  216. var response = await httpClient.DeleteAsync($"files/{fileId}");
  217. return response.IsSuccessStatusCode;
  218. }
  219. /// <summary>
  220. /// Word(.docx)二进制流转PDF二进制流。
  221. /// </summary>
  222. /// <param name="docxStream">Word二进制流(仅支持.docx)</param>
  223. /// <param name="saveDirectory">PDF保存目录,空则使用程序目录下 temp/pdf</param>
  224. /// <param name="outputFileName">输出PDF文件名(可不带后缀)</param>
  225. /// <returns>PDF二进制流(MemoryStream)</returns>
  226. public static MemoryStream ConvertDocxStreamToPdfStream(
  227. Stream docxStream,
  228. string? saveDirectory = null,
  229. string? outputFileName = null)
  230. {
  231. if (docxStream == null || !docxStream.CanRead)
  232. {
  233. throw new ArgumentException("Word二进制流不可读或为空", nameof(docxStream));
  234. }
  235. if (docxStream.CanSeek)
  236. {
  237. docxStream.Position = 0;
  238. }
  239. try
  240. {
  241. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
  242. AsposeHelper.removewatermark_v2180();
  243. var wordDoc = new Document(docxStream);
  244. using var pdfStream = new MemoryStream();
  245. wordDoc.Save(pdfStream, SaveFormat.Pdf);
  246. pdfStream.Position = 0;
  247. var pdfBytes = pdfStream.ToArray();
  248. // var finalSaveDirectory = string.IsNullOrWhiteSpace(saveDirectory)
  249. // ? global::System.IO.Path.Combine(AppContext.BaseDirectory, "temp", "pdf")
  250. // : saveDirectory;
  251. //
  252. // global::System.IO.Directory.CreateDirectory(finalSaveDirectory);
  253. //
  254. // var finalFileName = string.IsNullOrWhiteSpace(outputFileName)
  255. // ? $"docx_to_pdf_{DateTime.Now:yyyyMMdd_HHmmss_fff}.pdf"
  256. // : (outputFileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) ? outputFileName : $"{outputFileName}.pdf");
  257. //
  258. // var savePath = global::System.IO.Path.Combine(finalSaveDirectory, finalFileName);
  259. // global::System.IO.File.WriteAllBytes(savePath, pdfBytes);
  260. return new MemoryStream(pdfBytes);
  261. }
  262. catch (Exception ex)
  263. {
  264. var rootMessage = ex.InnerException?.Message ?? ex.Message;
  265. throw new InvalidOperationException($"Aspose文档转换失败: {rootMessage}", ex);
  266. }
  267. }
  268. }
  269. }