DoubaoService.cs 14 KB

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