From 7aad6b01579af556f2d8e6b4ed88e81f14f45703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Wed, 25 Mar 2026 00:25:05 +0100 Subject: [PATCH] Wiring up the AI calls. --- .../Interfaces/IWinoAccountApiClient.cs | 3 + .../Interfaces/IWinoAccountProfileService.cs | 3 + .../Translations/en_US/resources.json | 54 +++++++++++++++ .../WinoAccountProfileServiceTests.cs | 39 +++++++++++ Wino.Services/WinoAccountApiClient.cs | 65 +++++++++++++++++++ Wino.Services/WinoAccountProfileService.cs | 44 +++++++++++++ 6 files changed, 208 insertions(+) diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index c910df1c..4685acd4 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -21,6 +21,9 @@ public interface IWinoAccountApiClient Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task> GetAiStatusAsync(CancellationToken cancellationToken = default); + Task> SummarizeAsync(string html, CancellationToken cancellationToken = default); + Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); + Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task GetSettingsAsync(CancellationToken cancellationToken = default); diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index 6dfa914f..8326cde3 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -28,6 +28,9 @@ public interface IWinoAccountProfileService Task HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task> GetAiStatusAsync(CancellationToken cancellationToken = default); + Task> SummarizeAsync(string html, CancellationToken cancellationToken = default); + Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); + Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 5ad559a5..e86a3774 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1060,6 +1060,56 @@ "Composer_SmimeSignature": "S/MIME Signature", "Composer_SmimeEncryption": "S/MIME Encryption", "Composer_EmailTemplatesPlaceholder": "E-mail templates", + "Composer_AiSummarize": "Summarize with AI", + "Composer_AiTranslate": "Translate with AI", + "Composer_AiActions": "AI Actions", + "Composer_AiRewrite": "Rewrite with AI", + "Composer_AiRewritePolite": "Make it polite", + "Composer_AiRewritePoliteDescription": "Softens the wording while keeping the same intent.", + "Composer_AiRewriteAngry": "Make it direct", + "Composer_AiRewriteAngryDescription": "Uses a firmer, more forceful tone.", + "Composer_AiRewriteFormal": "Make it formal", + "Composer_AiRewriteFormalDescription": "Makes the message sound more professional and structured.", + "Composer_AiRewriteFriendly": "Make it friendly", + "Composer_AiRewriteFriendlyDescription": "Warms up the message with a more approachable tone.", + "Composer_AiRewriteShorter": "Make it shorter", + "Composer_AiRewriteShorterDescription": "Tightens the text and removes unnecessary detail.", + "Composer_AiRewriteClearer": "Make it clearer", + "Composer_AiRewriteClearerDescription": "Improves readability and makes the message easier to follow.", + "Composer_AiRewriteMode": "Rewrite tone", + "Composer_AiRewriteApply": "Apply rewrite", + "Composer_AiTranslateDialogTitle": "Translate with AI", + "Composer_AiTranslateDialogDescription": "Enter the target language or culture code, such as en-US, tr-TR, de-DE, or fr-FR.", + "Composer_AiTranslateApply": "Translate", + "Composer_AiTranslateLanguage": "Target language", + "Composer_AiTranslateCustomPlaceholder": "Enter culture code", + "Composer_AiTranslateLanguageEnglish": "English (en-US)", + "Composer_AiTranslateLanguageTurkish": "Turkish (tr-TR)", + "Composer_AiTranslateLanguageGerman": "German (de-DE)", + "Composer_AiTranslateLanguageFrench": "French (fr-FR)", + "Composer_AiTranslateLanguageSpanish": "Spanish (es-ES)", + "Composer_AiTranslateLanguageItalian": "Italian (it-IT)", + "Composer_AiTranslateLanguagePortugueseBrazil": "Portuguese (Brazil) (pt-BR)", + "Composer_AiTranslateLanguageDutch": "Dutch (nl-NL)", + "Composer_AiTranslateLanguagePolish": "Polish (pl-PL)", + "Composer_AiTranslateLanguageRussian": "Russian (ru-RU)", + "Composer_AiTranslateLanguageJapanese": "Japanese (ja-JP)", + "Composer_AiTranslateLanguageKorean": "Korean (ko-KR)", + "Composer_AiTranslateLanguageChineseSimplified": "Chinese, Simplified (zh-CN)", + "Composer_AiTranslateLanguageArabic": "Arabic (ar-SA)", + "Composer_AiTranslateLanguageHindi": "Hindi (hi-IN)", + "Composer_AiTranslateLanguageOther": "Other...", + "Composer_AiBusyTitle": "AI is already working", + "Composer_AiBusyMessage": "Please wait for the current AI action to finish.", + "Composer_AiSignInRequired": "Sign in to your Wino Account to use AI features.", + "Composer_AiMissingHtml": "There is no message content to send to Wino AI yet.", + "Composer_AiQuotaUnavailable": "The AI result was applied.", + "Composer_AiAppliedMessage": "The AI result was applied to the composer. Use Undo if you want to revert it.", + "Composer_AiSummarizeSuccessTitle": "AI summary applied", + "Composer_AiTranslateSuccessTitle": "AI translation applied", + "Composer_AiRewriteSuccessTitle": "AI rewrite applied", + "Composer_AiErrorTitle": "AI action failed", + "Reader_AiAppliedMessage": "The AI result is now shown for this message. Reopen the message to view the original content again.", "SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval", "SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.", "ContactsPage_Title": "Contacts", @@ -1272,6 +1322,10 @@ "WinoAccount_Error_ExternalLoginInvalid": "The external sign-in request is invalid.", "WinoAccount_Error_ExternalAuthStateInvalid": "The external sign-in state is invalid or expired.", "WinoAccount_Error_ExternalAuthCodeInvalid": "The external sign-in code is invalid or expired.", + "WinoAccount_Error_AiQuotaExceeded": "Your AI Pack usage limit has been reached for the current billing period.", + "WinoAccount_Error_AiHtmlEmpty": "There is no email content to process.", + "WinoAccount_Error_AiHtmlTooLarge": "This email is too large to process with Wino AI.", + "WinoAccount_Error_AiUnsupportedLanguage": "That language is not supported. Try a valid culture code such as en-US or tr-TR.", "WinoAccount_Error_Forbidden": "You do not have permission to perform this action.", "WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.", "WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.", diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index ad0631c1..498fbfd4 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -7,6 +7,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; using Wino.Services; @@ -26,6 +27,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime { _databaseService = new InMemoryDatabaseService(); await _databaseService.InitializeAsync(); + await _databaseService.Connection.CreateTableAsync(); _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object); } @@ -234,6 +236,43 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime _apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce); } + [Fact] + public async Task SummarizeAsync_ShouldPersistQuotaSnapshot() + { + var authResult = CreateAuthResult("first@example.com"); + + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); + + _apiClient + .Setup(x => x.SummarizeAsync("

Hello

", default)) + .ReturnsAsync(ApiEnvelope.Success( + new AiTextResultDto("

Summary

"), + new QuotaInfoDto( + true, + "Active", + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(29), + 1000, + 4, + 996, + new AiPackProductInfoDto("AI_PACK", 1000, 4.99m, "USD", "month")))); + + await _service.LoginAsync("first@example.com", "pw"); + + var response = await _service.SummarizeAsync("

Hello

"); + + response.IsSuccess.Should().BeTrue(); + response.Result?.Html.Should().Be("

Summary

"); + + var cachedSnapshot = await _service.GetCachedAddOnSnapshotAsync(); + cachedSnapshot.Should().NotBeNull(); + cachedSnapshot!.HasAiPack.Should().BeTrue(); + cachedSnapshot.UsageCount.Should().Be(4); + cachedSnapshot.UsageLimit.Should().Be(1000); + } + private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs index 77a3faba..1602ffcd 100644 --- a/Wino.Services/WinoAccountApiClient.cs +++ b/Wino.Services/WinoAccountApiClient.cs @@ -110,6 +110,33 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable public Task> GetAiStatusAsync(CancellationToken cancellationToken = default) => SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken); + public Task> SummarizeAsync(string html, CancellationToken cancellationToken = default) + => SendAuthorizedRequestAsync( + HttpMethod.Post, + "api/v1/ai/summarize", + new SummarizeRequest(html), + WinoAccountApiJsonContext.Default.SummarizeRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto, + cancellationToken); + + public Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default) + => SendAuthorizedRequestAsync( + HttpMethod.Post, + "api/v1/ai/translate", + new TranslateRequest(html, targetLanguage), + WinoAccountApiJsonContext.Default.TranslateRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto, + cancellationToken); + + public Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default) + => SendAuthorizedRequestAsync( + HttpMethod.Post, + "api/v1/ai/rewrite", + new RewriteRequest(html, mode), + WinoAccountApiJsonContext.Default.RewriteRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto, + cancellationToken); + public Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) { var endpoint = productId switch @@ -350,6 +377,40 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable } } + private async Task> SendAuthorizedRequestAsync(HttpMethod method, + string endpoint, + TRequest requestBody, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo> responseTypeInfo, + CancellationToken cancellationToken) + { + try + { + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync( + method, + endpoint, + () => JsonContent.Create(requestBody, requestTypeInfo)), + cancellationToken).ConfigureAwait(false); + + if (response == null) + { + return ApiEnvelope.Failure("MissingAccessToken"); + } + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var envelope = string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, responseTypeInfo); + + return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); + } + catch (Exception ex) + { + return ApiEnvelope.Failure(ex.Message); + } + } + private async Task CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func? contentFactory = null) { var accessToken = await GetAccessTokenAsync().ConfigureAwait(false); @@ -478,10 +539,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable [JsonSerializable(typeof(LogoutRequest))] [JsonSerializable(typeof(ResendEmailConfirmationRequest))] [JsonSerializable(typeof(ForgotPasswordRequest))] +[JsonSerializable(typeof(SummarizeRequest))] +[JsonSerializable(typeof(TranslateRequest))] +[JsonSerializable(typeof(RewriteRequest))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] +[JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index efda1927..3a0c2513 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -254,6 +254,15 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return response; } + public async Task> SummarizeAsync(string html, CancellationToken cancellationToken = default) + => await ExecuteAiOperationAsync(account => _apiClient.SummarizeAsync(html, cancellationToken), "summarize", cancellationToken).ConfigureAwait(false); + + public async Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default) + => await ExecuteAiOperationAsync(account => _apiClient.TranslateAsync(html, targetLanguage, cancellationToken), "translate", cancellationToken).ConfigureAwait(false); + + public async Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default) + => await ExecuteAiOperationAsync(account => _apiClient.RewriteAsync(html, mode, cancellationToken), "rewrite", cancellationToken).ConfigureAwait(false); + public async Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); @@ -409,6 +418,30 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return response.IsSuccess && response.Result?.HasAiPack == true; } + private async Task> ExecuteAiOperationAsync(Func>> executeAsync, + string operationName, + CancellationToken cancellationToken) + { + var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); + if (account == null) + { + return ApiEnvelope.Failure("MissingAccessToken"); + } + + var response = await executeAsync(account).ConfigureAwait(false); + if (!response.IsSuccess) + { + _logger.Warning("Failed to {Operation} HTML with AI for Wino account {Email}. Error code: {ErrorCode}", operationName, account.Email, response.ErrorCode); + } + + if (response.Quota != null) + { + await PersistAddOnCacheAsync(account.Id, Map(response.Quota)).ConfigureAwait(false); + } + + return response; + } + private async Task HasUnlimitedAccountsAsync(CancellationToken cancellationToken) { var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false); @@ -478,6 +511,17 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun cache.HasUnlimitedAccounts, new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc))); + private static AiStatusResultDto Map(QuotaInfoDto quota) + => new( + quota.HasAiPack, + quota.EntitlementStatus, + quota.CurrentPeriodStartUtc, + quota.CurrentPeriodEndUtc, + quota.MonthlyLimit, + quota.Used, + quota.Remaining, + quota.Product); + private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right) => left.Id == right.Id && string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&