diff --git a/Constants/XAiApiConstants.cs b/Constants/XAiApiConstants.cs index d9e53f5..3aae7de 100644 --- a/Constants/XAiApiConstants.cs +++ b/Constants/XAiApiConstants.cs @@ -9,11 +9,15 @@ namespace xAiApi.Constants // public const string XDILogTag = "XAiApi"; + + // public const string XAiApiIntroductionPromptName = "Introduction"; + public const string XAiApiContentExtractionPromptName = "ContentExtraction"; // public const string XAiDefaultModelName = "Gemma"; public const string XAiEmbeddingModelName = "Embed"; + public const string XAiDefaultVisionModelName = "Qwen"; public const string XAiDefaultThinkingModelName = "Qwen"; public const string XAiEmbeddingPromptName = "Embedding"; diff --git a/Controllers/XAiServiceControllerBase.cs b/Controllers/XAiServiceControllerBase.cs index 0b03124..11e1433 100644 --- a/Controllers/XAiServiceControllerBase.cs +++ b/Controllers/XAiServiceControllerBase.cs @@ -18,6 +18,7 @@ using xIdentityService.Interfaces; using Microsoft.Extensions.Logging; using xIdentityService.Controllers; using Microsoft.AspNetCore.Authorization; +using System.Linq; namespace xAiApi.Controllers { @@ -522,6 +523,61 @@ namespace xAiApi.Controllers } } #endregion + + // + #region Content Extraction ... + /// + /// Extract Files Content ... + /// + /// + /// + /// + /// + [HttpPost("ExtractContent")] + [Consumes("multipart/form-data")] + public async Task> ExtractContent( + [FromForm] IFormFileCollection files, + [FromForm] string extractors = null, + CancellationToken cancellationToken = default + ) { + // + try + { + // + // Validate ... + if (!files.HasChild()) + { + XException.InvalidArgs.Throw(); + } + + // + var userInfo = await GetUserInfo(); + var connectionId = GetConnectionId(); + + // + string[] extractorArr = null; + if (!extractors.IsNullOrEmpty()) + { + extractorArr = [.. extractors.ParseListString()]; + } + + // + var result = await aiService.ExtractContent( + files: files, + extractors: extractorArr, + cancellationToken: cancellationToken + ); + + // + return Ok(result); + } + catch (Exception ex) + { + var result = GetExceptionActionResult(ex); + return result; + } + } + #endregion // #region Data Actions ... diff --git a/Extensions/XModelsExtensions.cs b/Extensions/XModelsExtensions.cs index 0e8e712..4bdbf3f 100644 --- a/Extensions/XModelsExtensions.cs +++ b/Extensions/XModelsExtensions.cs @@ -1,10 +1,20 @@ +using System; +using OpenAI; using System.Linq; +using OllamaSharp; +using System.Net.Http; using xAiApi.Constants; using xAiModels.Models; +using System.Threading; +using System.ClientModel; using xCommons.Extensions; +using xAiModels.Constants; using xAiModels.Extensions; using xAiApi.Configurations; +using xExceptions.Constants; +using Microsoft.Extensions.AI; using System.Collections.Generic; +using System.ClientModel.Primitives; namespace xAiApi.Extensions { @@ -176,5 +186,107 @@ namespace xAiApi.Extensions // return result; } + + /// + /// Provide HttpClient for Common Usages ... + /// + /// + /// + public static HttpClient GetHttpClient(this string source) + { + // + var result = new HttpClient + { + // + // Disable timeout completely (not recommended for production) + Timeout = Timeout.InfiniteTimeSpan, + + // + BaseAddress = source.IsNullOrEmpty() + ? null + : new Uri(source), + + // + // OR set a very long timeout (e.g., 30 minutes for large file uploads) + // Timeout = TimeSpan.FromMinutes(30) + }; + + // + return result; + } + + /// + /// Instance and Retrieve Chat Client ... + /// + /// + /// + public static IChatClient GetClient(this XAiModelDescriptor source) + { + // + // Try to Initialize LLm ... + var model = source.LLM; + var apiKey = source.ApiKey; + var url = new Uri(source.Url); + var httpClient = source.Url.GetHttpClient(); + + // + IChatClient result = null; + switch (source.Provider) + { + // + case XAiModelProviderType.Ollama: + // + // Instance Ollama Client using Custom HttpCLient ... + var ollamaClient = new OllamaApiClient(httpClient, model); + result = + new ChatClientBuilder(ollamaClient) + .UseFunctionInvocation() + .Build(); + break; + + // + case XAiModelProviderType.OpenAI: + // + // Instance Ollama Client using Custom HttpCLient ... + var openAiClient = new OpenAIClient( + new ApiKeyCredential(apiKey.IsNullOrEmpty() ? XAiApiConstants.XOpenAINoKey : apiKey), + new OpenAIClientOptions + { + Endpoint = url, + Transport = new HttpClientPipelineTransport(httpClient) + } + ); + result = + new ChatClientBuilder( + openAiClient + .GetChatClient(model) + .AsIChatClient()) + .UseFunctionInvocation() + .Build(); + break; + + // + case XAiModelProviderType.DeepSeek: + break; + + // + case XAiModelProviderType.HuggingFace: + break; + + // + default: + break; + } + + // + if (result.IsNullOrDefault()) + { + XException.InvalidData.Throw(); + } + + // + return result; + } + } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXAudioFileContentExtractor.cs b/Interfaces/Extractors/IXAudioFileContentExtractor.cs new file mode 100644 index 0000000..af19793 --- /dev/null +++ b/Interfaces/Extractors/IXAudioFileContentExtractor.cs @@ -0,0 +1,5 @@ +namespace xAiApi.Interfaces.Extractors +{ + public interface IXAudioFileContentExtractor : IXFileContentExtractorBase + { } +} \ No newline at end of file diff --git a/Interfaces/Extractors/IXDocxFileContentExtractor.cs b/Interfaces/Extractors/IXDocxFileContentExtractor.cs index 3ec8aaf..a46d9ef 100644 --- a/Interfaces/Extractors/IXDocxFileContentExtractor.cs +++ b/Interfaces/Extractors/IXDocxFileContentExtractor.cs @@ -1,5 +1,5 @@ namespace xAiApi.Interfaces.Extractors { - public interface IXDocxFileContentExtractor : IXFileContentExtractor + public interface IXDocxFileContentExtractor : IXFileContentExtractorBase { } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXExcelFileContentExtractor.cs b/Interfaces/Extractors/IXExcelFileContentExtractor.cs index 9711643..1eafc25 100644 --- a/Interfaces/Extractors/IXExcelFileContentExtractor.cs +++ b/Interfaces/Extractors/IXExcelFileContentExtractor.cs @@ -1,5 +1,5 @@ namespace xAiApi.Interfaces.Extractors { - public interface IXExcelFileContentExtractor : IXFileContentExtractor + public interface IXExcelFileContentExtractor : IXFileContentExtractorBase { } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXFileContentExtractor.cs b/Interfaces/Extractors/IXFileContentExtractor.cs index ccb62db..d509fa5 100644 --- a/Interfaces/Extractors/IXFileContentExtractor.cs +++ b/Interfaces/Extractors/IXFileContentExtractor.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -6,9 +8,9 @@ using xAiModels.Models; namespace xAiApi.Interfaces.Extractors { /// - /// a Service for Extracting File Content ... + /// a Base Service for Extracting File Content ... /// - public interface IXFileContentExtractor + public interface IXFileContentExtractorBase { /// /// Check if this extractor supports the specified MIME type ... @@ -39,4 +41,54 @@ namespace xAiApi.Interfaces.Extractors CancellationToken cancellationToken = default ); } + + /// + /// a Service for Extracting File Content ... + /// + public interface IXFileContentExtractor + { + /// + /// Check if this extractor supports the specified MIME type ... + /// + /// + /// + /// + bool CanExtract( + string mimeType, + string[] extractors = null + ); + + /// + /// Extract text content from file stream ... + /// + /// + /// + /// + /// + /// + Task ExtractAsync( + Stream fileStream, + string mimeType, + string[] extractors = null, + CancellationToken cancellationToken = default + ); + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + /// + Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + string[] extractors = null, + CancellationToken cancellationToken = default + ); + + } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXImageFileContentExtractor.cs b/Interfaces/Extractors/IXImageFileContentExtractor.cs index 2844cea..9fdef19 100644 --- a/Interfaces/Extractors/IXImageFileContentExtractor.cs +++ b/Interfaces/Extractors/IXImageFileContentExtractor.cs @@ -1,5 +1,5 @@ namespace xAiApi.Interfaces.Extractors { - public interface IXImageFileContentExtractor : IXFileContentExtractor + public interface IXImageFileContentExtractor : IXFileContentExtractorBase { } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXPdfFileContentExtractor.cs b/Interfaces/Extractors/IXPdfFileContentExtractor.cs index ed74569..41bc559 100644 --- a/Interfaces/Extractors/IXPdfFileContentExtractor.cs +++ b/Interfaces/Extractors/IXPdfFileContentExtractor.cs @@ -1,5 +1,5 @@ namespace xAiApi.Interfaces.Extractors { - public interface IXPdfFileContentExtractor : IXFileContentExtractor + public interface IXPdfFileContentExtractor : IXFileContentExtractorBase { } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXPlainTextFileContentExtractor.cs b/Interfaces/Extractors/IXPlainTextFileContentExtractor.cs index cace3a5..ae43f76 100644 --- a/Interfaces/Extractors/IXPlainTextFileContentExtractor.cs +++ b/Interfaces/Extractors/IXPlainTextFileContentExtractor.cs @@ -1,5 +1,5 @@ namespace xAiApi.Interfaces.Extractors { - public interface IXPlainTextFileContentExtractor : IXFileContentExtractor + public interface IXPlainTextFileContentExtractor : IXFileContentExtractorBase { } } \ No newline at end of file diff --git a/Interfaces/Extractors/IXVisionFileContentExtractor.cs b/Interfaces/Extractors/IXVisionFileContentExtractor.cs new file mode 100644 index 0000000..f33ac52 --- /dev/null +++ b/Interfaces/Extractors/IXVisionFileContentExtractor.cs @@ -0,0 +1,5 @@ +namespace xAiApi.Interfaces.Extractors +{ + public interface IXVisionFileContentExtractor : IXFileContentExtractorBase + { } +} \ No newline at end of file diff --git a/Interfaces/IXAiServiceBase.cs b/Interfaces/IXAiServiceBase.cs index 69009ff..d7fe355 100644 --- a/Interfaces/IXAiServiceBase.cs +++ b/Interfaces/IXAiServiceBase.cs @@ -51,19 +51,6 @@ namespace xAiApi.Interfaces CancellationToken cancellationToken = default ); - /// - /// Get LLM Client instance for Communicating with LLM ... - /// - /// - IChatClient GetClient(); - - /// - /// Create Custom HttpClient for Communicating with LLM API ... - /// - /// - /// - HttpClient GetHttpClient(string url = null); - /// /// Prepare a Message History List for LLM Communication based on Prompt ... /// @@ -209,6 +196,22 @@ namespace xAiApi.Interfaces CancellationToken cancellationToken = default ); #endregion + + // + #region Content Extraction ... + /// + /// Try to Extract Files Content ... + /// + /// + /// + /// + /// + Task ExtractContent( + IFormFileCollection files, + string[] extractors = null, + CancellationToken cancellationToken = default + ); + #endregion // #region Data Actions ... diff --git a/Interfaces/IXAiServiceControllerBase.cs b/Interfaces/IXAiServiceControllerBase.cs index 3dc95e4..1990353 100644 --- a/Interfaces/IXAiServiceControllerBase.cs +++ b/Interfaces/IXAiServiceControllerBase.cs @@ -5,8 +5,8 @@ using xAiModels.Models; using xAiModels.Models.Dtos; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; using Microsoft.AspNetCore.Http; +using System.Collections.Generic; namespace xAiApi.Interfaces { @@ -152,6 +152,24 @@ namespace xAiApi.Interfaces ); #endregion + // + #region Content Extraction ... + /// + /// Extract Files Content ... + /// + /// + /// + /// + /// + [HttpPost("ExtractContent")] + [Consumes("multipart/form-data")] + Task> ExtractContent( + [FromForm] IFormFileCollection files, + [FromForm] string extractors = null, + CancellationToken cancellationToken = default + ); + #endregion + // #region Data Actions ... /// diff --git a/Models/ara.traineddata b/Models/ara.traineddata new file mode 100644 index 0000000..42e5c4b Binary files /dev/null and b/Models/ara.traineddata differ diff --git a/Models/eng.traineddata b/Models/eng.traineddata new file mode 100644 index 0000000..f4744c2 Binary files /dev/null and b/Models/eng.traineddata differ diff --git a/Models/fas.traineddata b/Models/fas.traineddata new file mode 100644 index 0000000..a0b9843 Binary files /dev/null and b/Models/fas.traineddata differ diff --git a/Models/ggml-base.bin b/Models/ggml-base.bin new file mode 100644 index 0000000..d55d123 Binary files /dev/null and b/Models/ggml-base.bin differ diff --git a/Models/ggml-small.bin b/Models/ggml-small.bin new file mode 100644 index 0000000..20e35ad Binary files /dev/null and b/Models/ggml-small.bin differ diff --git a/Providers/Extractors/XAudioFileContentExtractor.cs b/Providers/Extractors/XAudioFileContentExtractor.cs new file mode 100644 index 0000000..02aad55 --- /dev/null +++ b/Providers/Extractors/XAudioFileContentExtractor.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Linq; +using Whisper.net; +using System.Threading; +using xAiModels.Models; +using System.Threading.Tasks; +using xAiApi.Interfaces.Extractors; +using DocumentFormat.OpenXml.EMMA; + +namespace xAiApi.Providers.Extractors +{ + /// + /// Extracts text content from Audio files ... + /// + public class XAudioFileContentExtractor : IXAudioFileContentExtractor + { + /// + /// Supported MIME Types ... + /// + private static readonly string[] SupportedMimeTypes = + [ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/wave", + "audio/ogg", + "audio/m4a", + "audio/mp4", + "audio/webm" + ]; + + private readonly string language; + private readonly string whisperModelPath; + + public XAudioFileContentExtractor() : this( + language: "fa", + whisperModelPath: "Models/ggml-base.bin" + ) + { } + + public XAudioFileContentExtractor( + string whisperModelPath = "Models/ggml-base.bin", + string language = "fa" + ) + { + // + this.language = language; + this.whisperModelPath = whisperModelPath; + } + + /// + /// Check if this extractor supports the specified MIME type ... + /// + public bool CanExtract(string mimeType) + { + // + return SupportedMimeTypes.Contains( + mimeType?.ToLowerInvariant() ?? string.Empty + ); + } + + /// + /// Extract text content from file stream ... + /// + public async Task ExtractAsync( + Stream fileStream, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var result = await ExtractRichAsync( + fileStream, + "audio", + mimeType, + cancellationToken + ); + + // + return result.AudioTranscript; + } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var result = new XFileExtractionResult + { + FileName = fileName, + MimeType = mimeType + }; + + // + try + { + // + using var memoryStream = new MemoryStream(); + await fileStream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + // + using var wavStream = await ConvertToWavAsync( + memoryStream, + cancellationToken + ); + + // + using var factory = WhisperFactory.FromPath(whisperModelPath); + using var processor = factory.CreateBuilder() + .WithLanguage(language) + .Build(); + + // + var segments = new System.Text.StringBuilder(); + await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken)) + { + segments.Append(segment.Text); + } + + // + result.AudioTranscript = segments.ToString().Trim(); + result.Text = result.AudioTranscript; + } + catch (Exception ex) + { + result.ErrorMessage = $"Audio extraction failed: {ex.Message}"; + } + + // + return result; + } + + /// + /// Audio Format Converting ... + /// + private async Task ConvertToWavAsync( + Stream inputStream, + CancellationToken cancellationToken + ) + { + // + // TODO: Complete this ... + return await Task.FromResult(inputStream); + } + } +} \ No newline at end of file diff --git a/Providers/Extractors/XDocxFileContentExtractor.cs b/Providers/Extractors/XDocxFileContentExtractor.cs index a67b138..5f4fea0 100644 --- a/Providers/Extractors/XDocxFileContentExtractor.cs +++ b/Providers/Extractors/XDocxFileContentExtractor.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; using System.Threading; +using xAiModels.Models; using System.Threading.Tasks; using xAiApi.Interfaces.Extractors; using DocumentFormat.OpenXml.Drawing; @@ -75,5 +76,39 @@ namespace xAiApi.Providers.Extractors // return stringBuilder.ToString(); } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var content = await ExtractAsync( + mimeType: mimeType, + fileStream: fileStream, + cancellationToken: cancellationToken + ); + + // + var result = new XFileExtractionResult + { + Text = content, + FileName = fileName, + MimeType = mimeType + }; + + // + return result; + } } } \ No newline at end of file diff --git a/Providers/Extractors/XExcelFileContentExtractor.cs b/Providers/Extractors/XExcelFileContentExtractor.cs index 76326d4..44ad1f0 100644 --- a/Providers/Extractors/XExcelFileContentExtractor.cs +++ b/Providers/Extractors/XExcelFileContentExtractor.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using ExcelDataReader; using System.Threading; +using xAiModels.Models; using System.Threading.Tasks; using xAiApi.Interfaces.Extractors; @@ -89,5 +90,39 @@ namespace xAiApi.Providers.Extractors // return stringBuilder.ToString(); } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var content = await ExtractAsync( + mimeType: mimeType, + fileStream: fileStream, + cancellationToken: cancellationToken + ); + + // + var result = new XFileExtractionResult + { + Text = content, + FileName = fileName, + MimeType = mimeType + }; + + // + return result; + } } } \ No newline at end of file diff --git a/Providers/Extractors/XFileContentExtractor.cs b/Providers/Extractors/XFileContentExtractor.cs index d56288a..5506976 100644 --- a/Providers/Extractors/XFileContentExtractor.cs +++ b/Providers/Extractors/XFileContentExtractor.cs @@ -1,15 +1,14 @@ -using System; using System.IO; using System.Linq; using System.Threading; -using System.Reflection; +using xAiModels.Models; using xCommons.Extensions; using xExceptions.Constants; using System.Threading.Tasks; using System.Collections.Generic; using xAiApi.Interfaces.Extractors; -namespace xAiApi.Providers +namespace xAiApi.Providers.Extractors { /// /// Composite extractor that delegates to appropriate extractor @@ -18,67 +17,50 @@ namespace xAiApi.Providers public class XFileContentExtractor : IXFileContentExtractor { // - private readonly IList extractors = []; + private readonly IList _extractors = []; - public XFileContentExtractor() + public XFileContentExtractor( + IEnumerable availableExtractors + ) { // - var path = Path.Combine(AppContext.BaseDirectory); - var files = Directory.GetFiles(path, "*.dll"); - foreach (var dll in files) - { - // - // Retrieve DLL Assembly ... - var assembly = Assembly.LoadFrom(dll); - - // - // Extract Registerar Types ... - var extractorTypes = assembly - .GetExportedTypes() - .Where(t => - !t.IsAbstract && - typeof(IXFileContentExtractor).IsAssignableFrom(t)); - foreach (var extractorType in extractorTypes) - { - // - // Ignore XFileContentExtractor ... - if (extractorType.FullName.Contains(nameof(XFileContentExtractor))) - { - continue; - } - - // - // Create Instance of Service ... - var instance = (IXFileContentExtractor)Activator.CreateInstance(extractorType); - if (!instance.IsNull()) - { - // - // Add Registerar Instance to Collection ... - extractors.Add(instance); - } - } - } + _extractors = [.. availableExtractors + .Where(e => e.GetType() != typeof(XFileContentExtractor))]; } /// /// Check if this extractor supports the specified MIME type ... /// - public bool CanExtract(string mimeType) + /// + /// + /// + public bool CanExtract( + string mimeType, + string[] extractors = null + ) { - return extractors.Any(e => e.CanExtract(mimeType)); + // + return GetExtractors(extractors) + .Any(e => e.CanExtract(mimeType)); } /// /// Extract text content from file stream ... /// + /// + /// + /// + /// + /// public async Task ExtractAsync( Stream fileStream, string mimeType, + string[] extractors = null, CancellationToken cancellationToken = default ) { // - var extractor = extractors + var extractor = GetExtractors(extractors) .FirstOrDefault(e => e.CanExtract(mimeType)); if (extractor.IsNull()) { @@ -99,5 +81,74 @@ namespace xAiApi.Providers // return result; } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + string[] extractors = null, + CancellationToken cancellationToken = default + ) + { + // + var extractor = GetExtractors(extractors) + .FirstOrDefault(e => e.CanExtract(mimeType)); + if (extractor.IsNull()) + { + // + XException.NotAllowed.Throw( + $"Unsupported file type: {mimeType}" + ); + } + + // + var result = await extractor + .ExtractRichAsync( + mimeType: mimeType, + fileName: fileName, + fileStream: fileStream, + cancellationToken: cancellationToken + ); + + // + return result; + } + + /// + /// Extract Provided Extractors ... + /// + /// + /// + private IList GetExtractors(string[] extractors = null) + { + // + var result = new List(); + + // + if (extractors.IsNull() || !extractors.HasChild()) + { + // + result = [.. _extractors]; + } + else + { + // + result = [.. _extractors + .Where(e => extractors + .Any(ex => e.GetType().FullName.Contains(ex)))]; + } + + // + return result; + } } } \ No newline at end of file diff --git a/Providers/Extractors/XImageFileContentExtractor.cs b/Providers/Extractors/XImageFileContentExtractor.cs index 4b53475..042eefd 100644 --- a/Providers/Extractors/XImageFileContentExtractor.cs +++ b/Providers/Extractors/XImageFileContentExtractor.cs @@ -28,6 +28,9 @@ namespace xAiApi.Providers.Extractors /// private readonly bool _enableOcrFallback; + public XImageFileContentExtractor() : this(enableOcrFallback: true) + { } + public XImageFileContentExtractor(bool enableOcrFallback = true) { _enableOcrFallback = enableOcrFallback; @@ -132,9 +135,7 @@ namespace xAiApi.Providers.Extractors ) { // - // TODO: - // پیاده‌سازی با Tesseract یا هر کتابخانه OCR دیگر - // در حال حاضر یک placeholder است + // TODO: Complete this ... return await Task.FromResult(string.Empty); } } diff --git a/Providers/Extractors/XPdfFileContentExtractor.cs b/Providers/Extractors/XPdfFileContentExtractor.cs index 14834d1..629018f 100644 --- a/Providers/Extractors/XPdfFileContentExtractor.cs +++ b/Providers/Extractors/XPdfFileContentExtractor.cs @@ -2,12 +2,13 @@ using System; using System.IO; using System.Linq; using System.Text; -using UglyToad.PdfPig; +using PdfiumViewer; using System.Threading; +using xAiModels.Models; using System.Threading.Tasks; using xAiApi.Interfaces.Extractors; -namespace xAiApi.Providers +namespace xAiApi.Providers.Extractors { /// /// Extracts content from PDF files using PdfPig ... @@ -22,6 +23,26 @@ namespace xAiApi.Providers "application/pdf" ]; + /// + /// Minimum Length for Fallback Using Images ... + /// + private const int MinTextLengthThreshold = 50; + + /// + /// Max Pages for Falling Back Using Images ... + /// + private const int MaxPagesForImageFallback = 10; + + /// + /// Default DPI for Rendering Images ... + /// + private const int RenderDpi = 150; + + /// + /// Max Rendered Image Width ... + /// + private const int MaxImageWidth = 2000; + /// /// Check if this extractor supports the specified MIME type ... /// @@ -41,21 +62,100 @@ namespace xAiApi.Providers string mimeType, CancellationToken cancellationToken = default ) + { + // + var result = await ExtractRichAsync( + fileStream, + "document.pdf", + mimeType, + cancellationToken + ); + + // + return result.Text; + } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var result = new XFileExtractionResult + { + FileName = fileName, + MimeType = mimeType + }; + + // + using var memoryStream = new MemoryStream(); + await fileStream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + // + var extractedText = await ExtractTextAsync( + memoryStream, + cancellationToken + ); + if (IsTextSufficient(extractedText)) + { + // PDF متنی است + result.Text = extractedText; + } + else + { + // PDF اسکن‌شده است - Fallback به تصویر + memoryStream.Position = 0; + result = await FallbackToImageExtractionAsync( + memoryStream, + fileName, + mimeType, + cancellationToken + ); + result.UsedImageFallback = true; + } + + // + return result; + } + + /// + /// Extract Text From PDF Files using Common way ... + /// + /// + /// + /// + private async Task ExtractTextAsync( + Stream pdfStream, + CancellationToken cancellationToken + ) { // var result = await Task.Run(() => { // var sb = new StringBuilder(); - using var document = PdfDocument.Open(fileStream); + using var document = UglyToad.PdfPig.PdfDocument.Open(pdfStream); foreach (var page in document.GetPages()) { // - var text = page.Text; - - // - sb.AppendLine(text); - sb.AppendLine(); + var text = page.Text?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + // + sb.AppendLine(text); + sb.AppendLine(); + } } // @@ -65,5 +165,129 @@ namespace xAiApi.Providers // return result; } + + /// + /// Check Extracted Content is Sufficient or not ... + /// if not try to Falling Back using Images ... + /// + private bool IsTextSufficient(string text) + { + // + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + // + // حذف فاصله‌ها و بررسی طول + var cleanText = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray()); + + // + return cleanText.Length >= MinTextLengthThreshold; + } + + /// + /// Fallback Converts PDF Pages to Images ... + /// + private async Task FallbackToImageExtractionAsync( + Stream pdfStream, + string fileName, + string mimeType, + CancellationToken cancellationToken + ) + { + // + var result = new XFileExtractionResult + { + FileName = fileName, + MimeType = mimeType, + UsedImageFallback = true + }; + + // + await Task.Run(() => + { + // + try + { + // + using var document = PdfDocument.Load(pdfStream); + + // + var pageCount = Math.Min( + document.PageCount, + MaxPagesForImageFallback + ); + + // + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) + { + // + if (cancellationToken.IsCancellationRequested) + { + break; + } + + // + try + { + // + var pageSize = document.PageSizes[pageIndex]; + + // + var scaleFactor = RenderDpi / 72.0; + var renderWidth = (int)Math.Ceiling(pageSize.Width * scaleFactor); + var renderHeight = (int)Math.Ceiling(pageSize.Height * scaleFactor); + + // + if (renderWidth > MaxImageWidth) + { + // + var ratio = (double)MaxImageWidth / renderWidth; + renderWidth = MaxImageWidth; + renderHeight = (int)(renderHeight * ratio); + } + + // + using var bitmap = document.Render( + dpiX: RenderDpi, + dpiY: RenderDpi, + page: pageIndex, + width: renderWidth, + forPrinting: false, + height: renderHeight + ); + + // + byte[] imageBytes; + using (var memoryStream = new MemoryStream()) + { + // + bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + imageBytes = memoryStream.ToArray(); + } + + // + result.Images.Add(new XExtractedImage + { + Bytes = imageBytes, + MimeType = "image/png", + PageNumber = pageIndex + 1, + Description = $"Page {pageIndex + 1} of {fileName} ({renderWidth}x{renderHeight})" + }); + } + catch (Exception) + { } + } + } + catch (Exception ex) + { + result.ErrorMessage = $"PDF image extraction failed: {ex.Message}"; + } + }, cancellationToken); + + // + return result; + } } } \ No newline at end of file diff --git a/Providers/Extractors/XPlainTextFileContentExtractor.cs b/Providers/Extractors/XPlainTextFileContentExtractor.cs index c087b32..b6ef9b6 100644 --- a/Providers/Extractors/XPlainTextFileContentExtractor.cs +++ b/Providers/Extractors/XPlainTextFileContentExtractor.cs @@ -2,10 +2,11 @@ using System; using System.IO; using System.Linq; using System.Threading; +using xAiModels.Models; using System.Threading.Tasks; using xAiApi.Interfaces.Extractors; -namespace xAiApi.Providers +namespace xAiApi.Providers.Extractors { /// /// Extracts content from plain text files (txt, md, csv, json, xml) ... @@ -50,5 +51,39 @@ namespace xAiApi.Providers using var reader = new StreamReader(fileStream); return await reader.ReadToEndAsync(); } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var content = await ExtractAsync( + mimeType: mimeType, + fileStream: fileStream, + cancellationToken: cancellationToken + ); + + // + var result = new XFileExtractionResult + { + Text = content, + FileName = fileName, + MimeType = mimeType + }; + + // + return result; + } } } \ No newline at end of file diff --git a/Providers/Extractors/XVisionFileContentExtractor.cs b/Providers/Extractors/XVisionFileContentExtractor.cs new file mode 100644 index 0000000..cf0abdd --- /dev/null +++ b/Providers/Extractors/XVisionFileContentExtractor.cs @@ -0,0 +1,272 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using xAiApi.Constants; +using xAiModels.Models; +using xAiApi.Extensions; +using xAiApi.Interfaces; +using xCommons.Extensions; +using xAiModels.Extensions; +using xAiApi.Configurations; +using xExceptions.Constants; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using System.Collections.Generic; +using xAiApi.Interfaces.Extractors; +using System.Runtime.CompilerServices; + +namespace xAiApi.Providers.Extractors +{ + /// + /// Extracts content from files using Vision Model ... + /// + public class XVisionFileContentExtractor : IXVisionFileContentExtractor + { + /// + /// Supported MIME Types ... + /// + private static readonly string[] SupportedMimeTypes = + [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/bmp", + "application/pdf", + ]; + + // + private readonly string prompt; + private readonly XAiModelDescriptor descriptor; + + public XVisionFileContentExtractor( + XAiApiConfiguration configuration, + string promptName = XAiApiConstants.XAiApiContentExtractionPromptName, + string modelName = XAiApiConstants.XAiDefaultVisionModelName + ) + { + // + // Retrieve Descriptoir ... + if (modelName.IsNullOrEmpty()) + { + XException.InvalidArgs.Throw(); + } + descriptor = configuration.GetModel(modelName); + + // + // Retrieve Extraction Prompt ... + if (promptName.IsNullOrEmpty()) + { + XException.InvalidArgs.Throw(); + } + prompt = configuration.GetPrompt( + name: promptName, + @params: null + ); + + // + // Validate Requirements ... + if (prompt.IsNullOrEmpty() || descriptor.IsNullOrDefault()) + { + XException.InvalidData.Throw(); + } + } + + /// + /// Check if this extractor supports the specified MIME type ... + /// + public bool CanExtract(string mimeType) + { + // + return SupportedMimeTypes.Contains( + mimeType?.ToLowerInvariant() ?? string.Empty + ); + } + + /// + /// Extract text content from file stream ... + /// + public async Task ExtractAsync( + Stream fileStream, + string mimeType, + CancellationToken cancellationToken = default + ) + { + var result = await ExtractRichAsync(fileStream, "file", mimeType, cancellationToken); + return result.Text; + } + + /// + /// Extract content from stream as Rich Result ... + /// + /// + /// + /// + /// + /// + public async Task ExtractRichAsync( + Stream fileStream, + string fileName, + string mimeType, + CancellationToken cancellationToken = default + ) + { + // + var result = new XFileExtractionResult + { + FileName = fileName, + MimeType = mimeType + }; + + // + try + { + // + using var memoryStream = new MemoryStream(); + await fileStream.CopyToAsync(memoryStream, cancellationToken); + var imageBytes = memoryStream.ToArray(); + var messages = new[] + { + // + new ChatMessage(ChatRole.User, new[] + { + new DataContent(imageBytes, mimeType) + }) + }; + + // + var extractedContent = await RequestOCRAsync( + messages: messages, + cancellationToken: cancellationToken + ); + if (!extractedContent.IsNullOrEmpty()) + { + result.Text = extractedContent.Trim(); + } + else + { + result.ErrorMessage = "there is not any Extracted Content ..."; + } + } + catch (Exception ex) + { + result.ErrorMessage = $"Error in Vision Extraction: {ex.Message} ..."; + } + + // + return result; + } + + // + #region OCR ... + /// + /// Request for Doing OCR on Given Data Contents ... + /// + /// + /// + /// + public virtual async Task RequestOCRAsync( + IList messages, + CancellationToken cancellationToken = default + ) + { + // + // Validate ... + if (!messages.HasChild()) + { + XException.InvalidArgs.Throw(); + } + + // + using var client = descriptor.GetClient(); + + // + // Preparing Extraction Prompt Message ... + var pMessage = new ChatMessage( + ChatRole.System, + prompt + ); + + // + messages = [pMessage, .. messages]; + + // + var response = await client.GetResponseAsync( + messages: messages, + cancellationToken: cancellationToken + ); + + // + // Validate Response ... + if (!response.IsValid()) + { + // + // Dispose Client ... + client.Dispose(); + XException.ActionFailed.Throw(); + } + + // + // Retrieve Response Text ... + var result = response.Text; + + // + return result; + } + + /// + /// Request for Doing OCR on Given Data Contents as Stream ... + /// + /// + /// + /// + public virtual async IAsyncEnumerable RequestOCRAsEnumerable( + IList messages, + [EnumeratorCancellation] + CancellationToken cancellationToken = default + ) + { + // + // Validate ... + if (!messages.HasChild()) + { + XException.InvalidArgs.Throw(); + } + + // + using var client = descriptor.GetClient(); + + // + // Preparing Extraction Prompt Message ... + var pMessage = new ChatMessage( + ChatRole.System, + prompt + ); + + // + messages = [pMessage, .. messages]; + + // + var enumerable = client.GetStreamingResponseAsync( + options: null, + messages: messages + ); + + // + await foreach (var res in enumerable) + { + // + // Cancellation Token ... + if (cancellationToken.IsCancellationRequested) + { + yield break; + } + + // + yield return res.Text; + } + } + #endregion + } +} \ No newline at end of file diff --git a/Providers/XAIServiceBase.cs b/Providers/XAIServiceBase.cs index 63ad345..846fbb3 100644 --- a/Providers/XAIServiceBase.cs +++ b/Providers/XAIServiceBase.cs @@ -107,7 +107,7 @@ namespace xAiApi.Providers } // - using var client = GetClient(); + using var client = Descriptor.GetClient(); var history = GetHistory(prompt); var response = await client.GetResponseAsync( messages: history, @@ -152,7 +152,7 @@ namespace xAiApi.Providers } // - using var client = GetClient(); + using var client = Descriptor.GetClient(); var history = GetHistory(prompt); var enumerable = client.GetStreamingResponseAsync( options: null, @@ -174,106 +174,6 @@ namespace xAiApi.Providers } } - /// - /// Get LLM Client instance for Communicating with LLM ... - /// - /// - public virtual IChatClient GetClient() - { - // - // Try to Initialize LLm ... - var model = Descriptor.LLM; - var apiKey = Descriptor.ApiKey; - var url = new Uri(Descriptor.Url); - var httpClient = GetHttpClient(Descriptor.Url); - - // - IChatClient result = null; - switch (Descriptor.Provider) - { - // - case XAiModelProviderType.Ollama: - // - // Instance Ollama Client using Custom HttpCLient ... - var ollamaClient = new OllamaApiClient(httpClient, model); - result = - new ChatClientBuilder(ollamaClient) - .UseFunctionInvocation() - .Build(); - break; - - // - case XAiModelProviderType.OpenAI: - // - // Instance Ollama Client using Custom HttpCLient ... - var openAiClient = new OpenAIClient( - new ApiKeyCredential(apiKey.IsNullOrEmpty() ? XAiApiConstants.XOpenAINoKey : apiKey), - new OpenAIClientOptions - { - Endpoint = url, - Transport = new HttpClientPipelineTransport(httpClient) - } - ); - result = - new ChatClientBuilder( - openAiClient - .GetChatClient(model) - .AsIChatClient()) - .UseFunctionInvocation() - .Build(); - break; - - // - case XAiModelProviderType.DeepSeek: - break; - - // - case XAiModelProviderType.HuggingFace: - break; - - // - default: - break; - } - - // - if (result.IsNullOrDefault()) - { - XException.InvalidData.Throw(); - } - - // - return result; - } - - /// - /// Create Custom HttpClient for Communicating with LLM API ... - /// - /// - /// - public virtual HttpClient GetHttpClient(string url = null) - { - // - var result = new HttpClient - { - // - // Disable timeout completely (not recommended for production) - Timeout = Timeout.InfiniteTimeSpan, - - // - BaseAddress = url.IsNullOrEmpty() - ? null - : new Uri(url), - - // - // OR set a very long timeout (e.g., 30 minutes for large file uploads) - // Timeout = TimeSpan.FromMinutes(30) - }; - - // - return result; - } - /// /// Prepare a Message History List for LLM Communication based on Prompt ... /// @@ -366,7 +266,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> LoadProjects( + public virtual async Task> LoadProjects( string ownerId, string connectionId = null, bool includeDefaults = false, @@ -439,7 +339,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> LoadConversations( + public virtual async Task> LoadConversations( Guid projectId, string connectionId = null, CancellationToken cancellationToken = default @@ -470,7 +370,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> LoadMessages( + public virtual async Task> LoadMessages( Guid conversationId, CancellationToken cancellationToken = default ) @@ -502,7 +402,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> QueryProjects( + public virtual async Task> QueryProjects( string ownerId, XQuery query, CancellationToken cancellationToken = default @@ -534,7 +434,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> QueryConversations( + public virtual async Task> QueryConversations( string ownerId, Guid projectId, XQuery query, @@ -589,7 +489,7 @@ namespace xAiApi.Providers /// /// /// - public async Task> QueryMessages( + public virtual async Task> QueryMessages( string ownerId, Guid conversationId, XQuery query, @@ -646,7 +546,7 @@ namespace xAiApi.Providers /// /// /// - public async Task CreateProject( + public virtual async Task CreateProject( string ownerId, XAiProjectDto item, string connectionId = null, @@ -674,7 +574,7 @@ namespace xAiApi.Providers /// /// /// - public async Task CreateConversation( + public virtual async Task CreateConversation( string ownerId, Guid projectId, XAiConversationDto item, @@ -696,6 +596,91 @@ namespace xAiApi.Providers } #endregion + // + #region Content Extraction ... + /// + /// Try to Extract Files Content ... + /// + /// + /// + /// + /// + public async Task ExtractContent( + IFormFileCollection files, + string[] extractors = null, + CancellationToken cancellationToken = default + ) + { + // + if (!files.HasChild()) + { + XException.InvalidArgs.Throw(); + } + + // + var extractionResults = new List(); + foreach (var file in files) + { + // + if (file.IsNull() || file.Length == 0) + { + continue; + } + + // + var mimeType = file.ContentType?.ToLowerInvariant() ?? string.Empty; + if (!fileContentExtractor.CanExtract(mimeType, extractors)) + { + continue; + } + + // + try + { + // + using var stream = file.OpenReadStream(); + + // + var fr = await fileContentExtractor.ExtractRichAsync( + fileStream: stream, + mimeType: mimeType, + extractors: extractors, + fileName: file.FileName, + cancellationToken: cancellationToken + ); + + // + if (fr.IsValid()) + { + extractionResults.Add(fr); + } + } + catch (OperationCanceledException) + { + throw; + } + catch + { + continue; + } + } + + // + var result = string.Empty; + if (extractionResults.HasChild()) + { + // + extractionResults.ForEach(er => + { + result += $"File {er.FileName} Content:{Environment.NewLine}{er.Text ?? er.AudioTranscript}{Environment.NewLine}----"; + }); + } + + // + return result; + } + #endregion + // #region Data Actions ... /// @@ -710,7 +695,7 @@ namespace xAiApi.Providers /// /// /// - public async Task AskAsync( + public virtual async Task AskAsync( string prompt, string ownerId, Guid projectId, @@ -861,25 +846,69 @@ namespace xAiApi.Providers // // Since Prompt Message Has Id now, we can Attach Uploaded Files to it ... - IList fileContents = null; + IList fileContents = []; if (uploadedFiles.HasChild()) { // foreach (var uf in uploadedFiles) { // - await fileProvider.AddReference( - id: uf.Id, - userInfo: userInfo, - connectionId: connectionId, - forProvidedId: promptMessage.Id, - cancellationToken: cancellationToken, - providedFor: XAiServiceConstants.XAiMessageFilesIdentifier - ); - } + try + { + // + // Add Reference File to Prompt Message ... + await fileProvider.AddReference( + id: uf.Id, + userInfo: userInfo, + connectionId: connectionId, + forProvidedId: promptMessage.Id, + cancellationToken: cancellationToken, + providedFor: XAiServiceConstants.XAiMessageFilesIdentifier + ); - // - // TODO: Extract File Contents ... + // + // Handle File Content Extraction and Attach to Prompt Message ... + var fileDescriptor = await fileProvider.GetFileDescriptor( + id: uf.Id, + cancellationToken: cancellationToken + ); + if (fileDescriptor == null || fileDescriptor.Stream == null) + { + continue; + } + + // + var extractionResult = await fileContentExtractor.ExtractRichAsync( + fileStream: fileDescriptor.Stream, + fileName: uf.FileName ?? uf.Name, + mimeType: fileDescriptor.MIMEType, + cancellationToken: cancellationToken + ); + if (extractionResult.IsValid()) + { + // + var contents = extractionResult.ToAIContents(); + fileContents = [.. fileContents, .. contents]; + + // + // ذخیره Metadata برای ردیابی + logger.LogInformation( + "File {FileName} extracted successfully. Text: {HasText}, Images: {ImageCount}, Fallback: {UsedFallback}", + uf.FileName, + !string.IsNullOrEmpty(extractionResult.Text), + extractionResult.Images.Count, + extractionResult.UsedImageFallback + ); + } + + // + fileDescriptor.Stream.Dispose(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to extract content from file: {FileName}", uf.FileName); + } + } } // @@ -928,7 +957,7 @@ namespace xAiApi.Providers /// /// /// - public async IAsyncEnumerable AskAsEnumerable( + public virtual async IAsyncEnumerable AskAsEnumerable( string prompt, string ownerId, Guid projectId, @@ -1055,7 +1084,7 @@ namespace xAiApi.Providers // // Ask Questions From LLM ... - using var client = GetClient(); + using var client = Descriptor.GetClient(); var enumerable = client.GetStreamingResponseAsync( options: null, messages: history @@ -1202,7 +1231,7 @@ namespace xAiApi.Providers // history.Add(prompt); - using var client = GetClient(); + using var client = Descriptor.GetClient(); var response = await client.GetResponseAsync( messages: history, cancellationToken: cancellationToken diff --git a/Providers/XAiEmbeddingServiceBase.cs b/Providers/XAiEmbeddingServiceBase.cs index 02eabeb..97e9d35 100644 --- a/Providers/XAiEmbeddingServiceBase.cs +++ b/Providers/XAiEmbeddingServiceBase.cs @@ -9,6 +9,7 @@ using xAiApi.Extensions; using xAiApi.Interfaces; using xAiService.Helpers; using System.ClientModel; +using xCommons.Providers; using xCommons.Extensions; using xAiModels.Constants; using xAiModels.Extensions; @@ -22,7 +23,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Logging; using xAiApi.Interfaces.Extractors; using System.ClientModel.Primitives; -using xCommons.Providers; namespace xAiApi.Providers { @@ -44,12 +44,12 @@ namespace xAiApi.Providers IXFileContentExtractor fileContentExtractor, string embeddingModelName = XAiApiConstants.XAiEmbeddingModelName ) : base( - fileProvider, - dataProvider, - logger, - configuration, - validationProvider, - fileContentExtractor, + fileProvider, + dataProvider, + logger, + configuration, + validationProvider, + fileContentExtractor, null ) { @@ -199,7 +199,7 @@ namespace xAiApi.Providers new OpenAIClientOptions { Endpoint = new Uri(descriptor.Url), - Transport = new HttpClientPipelineTransport(GetHttpClient(descriptor.Url)) + Transport = new HttpClientPipelineTransport(descriptor.Url.GetHttpClient()) } ); result = openAiClient diff --git a/Startup.cs b/Startup.cs index 598bda1..139a696 100644 --- a/Startup.cs +++ b/Startup.cs @@ -23,6 +23,7 @@ using xCommons.Extensions; using xPushService.Helpers; using xCommons.Configurations; using System.Collections.Generic; +using xAiApi.Providers.Extractors; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; @@ -188,9 +189,24 @@ namespace xAiApi // // Register File Content Extractors ... - // services.AddSingleton(); - // services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // services.AddSingleton(); + // services.AddSingleton(sp => + // { + // // + // var extractors = sp.GetServices(); + // return new XFileContentExtractor(extractors); + // }); + // services.AddSingleton(sp => + // sp.GetRequiredService()); // // Register Default Ai Service ... diff --git a/appsettings.Development.json b/appsettings.Development.json index f9ea324..5ed4b88 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -66,6 +66,12 @@ "Description": "", "Params": [] }, + { + "Name": "ContentExtraction", + "Template": "You are an Expert Powerful OCR Engine. your job isReading all Contents in Given Document and Retrieve them as Raw Text by following these rules: 1. Converst all tables to Markdown format. 2. extract numbers, date and times and names exactly as is. 3. do not add any extra descriptions, introduction or summarization from yourself and just return extracted Content.", + "Description": "", + "Params": [] + }, { "Name": "Embedding", "Template": "your name is xSaherElmAi, you are a professional ai assistant which develped by SaherElm IT Center specified for Embedding Documents", diff --git a/xAiApi.csproj b/xAiApi.csproj index 24aa676..2e72bf9 100644 --- a/xAiApi.csproj +++ b/xAiApi.csproj @@ -27,6 +27,13 @@ + + + @@ -49,10 +56,19 @@ + + + + + + + + +