DoubaoService.cs 14 KB

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