Translation caching. New ai actions panel.

This commit is contained in:
Burak Kaan Köse
2026-04-03 11:56:25 +02:00
parent 8f16f553f5
commit 27e91316d3
20 changed files with 1150 additions and 23 deletions
+1
View File
@@ -298,6 +298,7 @@ public partial class App : WinoApplication,
{
services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<IMailDialogService, DialogService>();
services.AddSingleton<IAiActionOptionsService, AiActionOptionsService>();
services.AddTransient<IProviderService, ProviderService>();
services.AddSingleton<IAuthenticatorConfig, MailAuthenticatorConfiguration>();
services.AddSingleton<IAccountCalendarStateService, AccountCalendarStateService>();
@@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Specialized;
using System.Windows.Input;
@@ -9,9 +10,9 @@ using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.Xaml.Interactivity;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Menus;
using Wino.Mail.WinUI.Controls;
using Wino.Helpers;
using Wino.Mail.WinUI;
using Wino.Mail.WinUI.Controls;
namespace Wino.Behaviors;
@@ -41,14 +42,25 @@ public partial class BindableCommandBarBehavior : Behavior<CommandBar>
{
foreach (var item in enumerable)
{
if (item is ButtonBase button)
{
button.Click -= Button_Click;
}
DetachCommandElement(item);
}
}
}
private void DetachCommandElement(object item)
{
if (item is ButtonBase button)
{
button.Click -= Button_Click;
return;
}
if (item is AppBarElementContainer container && container.Content is IDisposable disposable)
{
disposable.Dispose();
}
}
private void UpdatePrimaryCommands()
{
if (AssociatedObject == null)
@@ -61,10 +73,7 @@ public partial class BindableCommandBarBehavior : Behavior<CommandBar>
{
foreach (var item in enumerableObjects)
{
if (item is ButtonBase button)
{
button.Click -= Button_Click;
}
DetachCommandElement(item);
}
}
@@ -72,10 +81,7 @@ public partial class BindableCommandBarBehavior : Behavior<CommandBar>
{
foreach (var item in secondaryObject)
{
if (item is ButtonBase button)
{
button.Click -= Button_Click;
}
DetachCommandElement(item);
}
}
@@ -135,7 +141,6 @@ public partial class BindableCommandBarBehavior : Behavior<CommandBar>
AssociatedObject.PrimaryCommands.Add(menuItem);
}
}
//if (dependencyObject is ICommandBarElement icommandBarElement)
//{
// if (dependencyObject is ButtonBase button)
@@ -0,0 +1,232 @@
<UserControl
x:Class="Wino.Mail.WinUI.Controls.AiActionsPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:models="using:Wino.Core.Domain.Models.Ai"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="root"
Loaded="OnLoaded"
Unloaded="OnUnloaded"
mc:Ignorable="d">
<Border
Padding="14"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{StaticResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="10">
<Grid>
<StackPanel Spacing="14">
<ProgressBar x:Name="BusyProgressBar" IsIndeterminate="True" Visibility="Collapsed" />
<StackPanel x:Name="LoadingPanel" Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="10">
<ProgressRing Width="20" Height="20" IsActive="True" />
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.AiActions_CheckingStatus, Mode=OneWay}" />
</StackPanel>
</StackPanel>
<StackPanel x:Name="SignedOutPanel" Spacing="14" Visibility="Collapsed">
<Border Height="120" Padding="18" CornerRadius="12">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#1A6EE7B7" />
<GradientStop Offset="0.55" Color="#2038BDF8" />
<GradientStop Offset="1" Color="#1A818CF8" />
</LinearGradientBrush>
</Border.Background>
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Width="56" Height="56" VerticalAlignment="Top" Background="#22FFFFFF" CornerRadius="28">
<FontIcon
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="26"
Foreground="White"
Glyph="&#xE945;" />
</Border>
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind domain:Translator.AiActions_SignedOutTitle, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.AiActions_SignedOutDescription, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
</Border>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
Click="SignInButton_Click"
Content="{x:Bind domain:Translator.Buttons_SignIn, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
<Button
Grid.Column="1"
Click="CreateAccountButton_Click"
Content="{x:Bind domain:Translator.Buttons_CreateAccount, Mode=OneWay}" />
</Grid>
</StackPanel>
<StackPanel x:Name="PurchasePanel" Spacing="14" Visibility="Collapsed">
<Border Padding="16" Background="{ThemeResource CardBackgroundFillColorTertiaryBrush}" CornerRadius="12">
<StackPanel Spacing="8">
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.AiActions_NoPackTitle, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.AiActions_NoPackDescription, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Padding="8,2" Background="{ThemeResource AccentFillColorDefaultBrush}" CornerRadius="8">
<TextBlock Foreground="White" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoPrice, Mode=OneWay}" />
</Border>
<Border Padding="8,2" Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}" CornerRadius="8">
<TextBlock Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoRequests, Mode=OneWay}" />
</Border>
</StackPanel>
</StackPanel>
</Border>
<Button
Click="PurchaseButton_Click"
Content="{x:Bind domain:Translator.WinoAccount_Management_AiPackGetButton, Mode=OneWay}"
HorizontalAlignment="Left"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
<StackPanel x:Name="ReadyPanel" Spacing="12" Visibility="Collapsed">
<Border Padding="12" Background="{ThemeResource CardBackgroundFillColorTertiaryBrush}" CornerRadius="10">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="36"
Height="36"
Background="{ThemeResource AccentFillColorDefaultBrush}"
CornerRadius="18"
Opacity="0.18" />
<FontIcon
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{StaticResource SymbolThemeFontFamily}"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE945;" />
<StackPanel Grid.Column="1" Spacing="3">
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.Composer_AiActions, Mode=OneWay}" />
<TextBlock
x:Name="UsageSummaryTextBlock"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
TextWrapping="WrapWholeWords" />
<TextBlock
x:Name="UsageResetTextBlock"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
</Border>
<controls:Segmented x:Name="ActionSelector" SelectionChanged="ActionSelector_SelectionChanged">
<controls:SegmentedItem x:Name="TranslateSegment" Content="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureTranslate, Mode=OneWay}">
<controls:SegmentedItem.Icon>
<SymbolIcon Symbol="Switch" />
</controls:SegmentedItem.Icon>
</controls:SegmentedItem>
<controls:SegmentedItem x:Name="RewriteSegment" Content="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureRewrite, Mode=OneWay}">
<controls:SegmentedItem.Icon>
<SymbolIcon Symbol="Edit" />
</controls:SegmentedItem.Icon>
</controls:SegmentedItem>
<controls:SegmentedItem x:Name="SummarizeSegment" Content="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureSummarize, Mode=OneWay}">
<controls:SegmentedItem.Icon>
<SymbolIcon Symbol="Bullets" />
</controls:SegmentedItem.Icon>
</controls:SegmentedItem>
</controls:Segmented>
<StackPanel x:Name="TranslateOptionsPanel" Spacing="8" Visibility="Collapsed">
<TextBlock
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.Composer_AiTranslateLanguage, Mode=OneWay}" />
<ComboBox
x:Name="TranslateLanguageComboBox"
SelectedItem="{x:Bind SelectedTranslateLanguageOption, Mode=TwoWay}"
SelectionChanged="TranslateLanguageComboBox_SelectionChanged">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:AiTranslateLanguageOption">
<TextBlock Text="{x:Bind Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button
x:Name="RunTranslateButton"
Click="RunTranslateButton_Click"
Content="{x:Bind domain:Translator.Composer_AiTranslateApply, Mode=OneWay}"
HorizontalAlignment="Left"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
<StackPanel x:Name="RewriteOptionsPanel" Spacing="8" Visibility="Collapsed">
<TextBlock
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.Composer_AiRewriteMode, Mode=OneWay}" />
<ComboBox
x:Name="RewriteModeComboBox"
SelectedItem="{x:Bind SelectedRewriteModeOption, Mode=TwoWay}"
SelectionChanged="RewriteModeComboBox_SelectionChanged">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:AiRewriteModeOption">
<TextBlock Text="{x:Bind Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock
x:Name="RewriteDescriptionTextBlock"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
TextWrapping="WrapWholeWords" />
<TextBox
x:Name="CustomRewriteTextBox"
PlaceholderText="{x:Bind domain:Translator.Composer_AiRewriteCustomPlaceholder, Mode=OneWay}"
Visibility="Collapsed" />
<Button
x:Name="RunRewriteButton"
Click="RunRewriteButton_Click"
Content="{x:Bind domain:Translator.Composer_AiRewriteApply, Mode=OneWay}"
HorizontalAlignment="Left"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,519 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.WinUI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Ai;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Common;
using Wino.Mail.WinUI.Services;
namespace Wino.Mail.WinUI.Controls;
public sealed partial class AiActionsPanel : UserControl, IDisposable
{
private readonly IWinoAccountProfileService _profileService = App.Current.Services.GetRequiredService<IWinoAccountProfileService>();
private readonly IStoreManagementService _storeManagementService = App.Current.Services.GetRequiredService<IStoreManagementService>();
private readonly IMailDialogService _dialogService = App.Current.Services.GetRequiredService<IMailDialogService>();
private readonly IAiActionOptionsService _optionsService = App.Current.Services.GetRequiredService<IAiActionOptionsService>();
private bool _disposedValue;
private bool _isRefreshing;
private bool _isBusy;
private AiActionType _lastConfigurableAction = AiActionType.Translate;
private CancellationTokenSource? _actionCancellationTokenSource;
private IReadOnlyList<AiTranslateLanguageOption> _translateOptions = Array.Empty<AiTranslateLanguageOption>();
private IReadOnlyList<AiRewriteModeOption> _rewriteOptions = Array.Empty<AiRewriteModeOption>();
[GeneratedDependencyProperty(DefaultValue = AiActionType.None)]
public partial AiActionType AvailableActions { get; set; }
[GeneratedDependencyProperty]
public partial IAiHtmlActionHost? HtmlHost { get; set; }
public AiTranslateLanguageOption? SelectedTranslateLanguageOption { get; set; }
public AiRewriteModeOption? SelectedRewriteModeOption { get; set; }
public AiActionsPanel()
{
InitializeComponent();
}
public void CancelPendingOperation()
{
_actionCancellationTokenSource?.Cancel();
}
public void Dispose()
{
if (_disposedValue)
{
return;
}
_disposedValue = true;
CancelAndDisposeActionCancellationToken();
}
partial void OnAvailableActionsChanged(AiActionType newValue)
{
UpdateActionAvailability();
ApplySelectedAction(SelectDefaultAction());
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
LoadOptions();
UpdateActionAvailability();
ApplySelectedAction(SelectDefaultAction());
_ = RefreshAvailabilityAsync();
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
CancelPendingOperation();
}
private void LoadOptions()
{
_translateOptions = _optionsService.GetTranslateLanguageOptions();
_rewriteOptions = _optionsService.GetRewriteModeOptions();
TranslateLanguageComboBox.ItemsSource = _translateOptions;
RewriteModeComboBox.ItemsSource = _rewriteOptions;
SelectedTranslateLanguageOption ??= _translateOptions.Count > 0 ? _translateOptions[0] : null;
SelectedRewriteModeOption ??= _rewriteOptions.Count > 0 ? _rewriteOptions[0] : null;
TranslateLanguageComboBox.SelectedItem = SelectedTranslateLanguageOption;
RewriteModeComboBox.SelectedItem = SelectedRewriteModeOption;
UpdateRewriteOptionState();
}
private void UpdateActionAvailability()
{
TranslateSegment.Visibility = HasAction(AiActionType.Translate) ? Visibility.Visible : Visibility.Collapsed;
RewriteSegment.Visibility = HasAction(AiActionType.Rewrite) ? Visibility.Visible : Visibility.Collapsed;
SummarizeSegment.Visibility = HasAction(AiActionType.Summarize) ? Visibility.Visible : Visibility.Collapsed;
}
private bool HasAction(AiActionType action) => (AvailableActions & action) == action;
private AiActionType SelectDefaultAction()
{
if (HasAction(AiActionType.Translate))
{
return AiActionType.Translate;
}
if (HasAction(AiActionType.Rewrite))
{
return AiActionType.Rewrite;
}
if (HasAction(AiActionType.Summarize))
{
return AiActionType.Summarize;
}
return AiActionType.None;
}
private void ApplySelectedAction(AiActionType action)
{
if (action is AiActionType.Translate or AiActionType.Rewrite)
{
_lastConfigurableAction = action;
}
ActionSelector.SelectedItem = action switch
{
AiActionType.Translate => TranslateSegment,
AiActionType.Rewrite => RewriteSegment,
AiActionType.Summarize => SummarizeSegment,
_ => null
};
TranslateOptionsPanel.Visibility = action == AiActionType.Translate ? Visibility.Visible : Visibility.Collapsed;
RewriteOptionsPanel.Visibility = action == AiActionType.Rewrite ? Visibility.Visible : Visibility.Collapsed;
}
public async Task RefreshAvailabilityAsync()
{
if (_isRefreshing || _disposedValue)
{
return;
}
_isRefreshing = true;
SetBusyUi(isBusy: false, showLoading: true);
try
{
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(true);
if (account == null)
{
UpdateUsageSummary(string.Empty, string.Empty);
UpdatePanelState(showSignedOut: true);
return;
}
var hasAiPack = await _storeManagementService.HasProductAsync(WinoAddOnProductType.AI_PACK).ConfigureAwait(true);
if (!hasAiPack)
{
UpdateUsageSummary(string.Empty, string.Empty);
UpdatePanelState(showPurchase: true);
return;
}
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(true);
if (aiStatusResponse.IsSuccess && aiStatusResponse.Result != null)
{
UpdateUsageSummary(CreateUsageSummary(aiStatusResponse.Result), CreateUsageResetText(aiStatusResponse.Result));
}
else
{
UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed, string.Empty);
}
ApplySelectedAction(SelectDefaultAction());
UpdatePanelState(showReady: true);
}
catch (OperationCanceledException)
{
}
catch (Exception)
{
UpdateUsageSummary(Translator.WinoAccount_Management_AiPackUsageLoadFailed, string.Empty);
UpdatePanelState(showReady: true);
}
finally
{
_isRefreshing = false;
SetBusyUi(_isBusy, showLoading: false);
}
}
private static string CreateUsageSummary(AiStatusResultDto aiStatus)
{
if (aiStatus.Used is int used && aiStatus.MonthlyLimit is int limit && limit > 0)
{
return string.Format(Translator.AiActions_UsageSummary, used, limit);
}
return Translator.WinoAccount_Management_AiPackUsageLoadFailed;
}
private static string CreateUsageResetText(AiStatusResultDto aiStatus)
{
return aiStatus.CurrentPeriodEndUtc is DateTimeOffset resetDateUtc
? string.Format(Translator.WinoAccount_Management_AiPackResets, resetDateUtc.LocalDateTime)
: string.Empty;
}
private void UpdatePanelState(bool showLoading = false, bool showSignedOut = false, bool showPurchase = false, bool showReady = false)
{
LoadingPanel.Visibility = showLoading ? Visibility.Visible : Visibility.Collapsed;
SignedOutPanel.Visibility = showSignedOut ? Visibility.Visible : Visibility.Collapsed;
PurchasePanel.Visibility = showPurchase ? Visibility.Visible : Visibility.Collapsed;
ReadyPanel.Visibility = showReady ? Visibility.Visible : Visibility.Collapsed;
}
private void UpdateUsageSummary(string usageText, string resetText)
{
UsageSummaryTextBlock.Text = usageText;
UsageResetTextBlock.Text = resetText;
UsageResetTextBlock.Visibility = string.IsNullOrWhiteSpace(resetText) ? Visibility.Collapsed : Visibility.Visible;
}
private void SetBusyUi(bool isBusy, bool showLoading)
{
_isBusy = isBusy;
BusyProgressBar.Visibility = isBusy ? Visibility.Visible : Visibility.Collapsed;
ActionSelector.IsEnabled = !isBusy;
TranslateLanguageComboBox.IsEnabled = !isBusy;
RewriteModeComboBox.IsEnabled = !isBusy;
CustomRewriteTextBox.IsEnabled = !isBusy;
RunTranslateButton.IsEnabled = !isBusy;
RunRewriteButton.IsEnabled = !isBusy;
SignedOutPanel.IsHitTestVisible = !isBusy;
PurchasePanel.IsHitTestVisible = !isBusy;
if (showLoading)
{
UpdatePanelState(showLoading: true);
}
else if (ReadyPanel.Visibility == Visibility.Visible)
{
UpdatePanelState(showReady: true);
}
else if (SignedOutPanel.Visibility == Visibility.Visible)
{
UpdatePanelState(showSignedOut: true);
}
else if (PurchasePanel.Visibility == Visibility.Visible)
{
UpdatePanelState(showPurchase: true);
}
}
private async void SignInButton_Click(object sender, RoutedEventArgs e)
{
if (_isBusy)
{
return;
}
var account = await _dialogService.ShowWinoAccountLoginDialogAsync();
if (account != null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.WinoAccount_LoginSuccessMessage, account.Email), InfoBarMessageType.Success);
}
await RefreshAvailabilityAsync();
}
private async void CreateAccountButton_Click(object sender, RoutedEventArgs e)
{
if (_isBusy)
{
return;
}
var account = await _dialogService.ShowWinoAccountRegistrationDialogAsync();
if (account != null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.WinoAccount_RegisterSuccessMessage, account.Email), InfoBarMessageType.Success);
}
await RefreshAvailabilityAsync();
}
private async void PurchaseButton_Click(object sender, RoutedEventArgs e)
{
if (_isBusy)
{
return;
}
SetBusyUi(isBusy: true, showLoading: false);
try
{
var purchaseResult = await _storeManagementService.PurchaseAsync(WinoAddOnProductType.AI_PACK).ConfigureAwait(true);
if (purchaseResult == StorePurchaseResult.NotPurchased)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_PurchaseStartFailed, InfoBarMessageType.Error);
return;
}
var syncResult = await _profileService.SyncStoreEntitlementsAsync().ConfigureAwait(true);
if (!syncResult.IsSuccess && !string.Equals(syncResult.ErrorCode, "MissingAccessToken", StringComparison.Ordinal))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_StoreSyncFailed, InfoBarMessageType.Error);
return;
}
if (purchaseResult == StorePurchaseResult.AlreadyPurchased)
{
_dialogService.InfoBarMessage(Translator.Info_PurchaseExistsTitle, Translator.Info_PurchaseExistsMessage, InfoBarMessageType.Warning);
}
else
{
_dialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
}
}
catch (OperationCanceledException)
{
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_PurchaseStartFailed, InfoBarMessageType.Error);
}
finally
{
SetBusyUi(isBusy: false, showLoading: false);
await RefreshAvailabilityAsync();
}
}
private void ActionSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ReferenceEquals(ActionSelector.SelectedItem, TranslateSegment))
{
ApplySelectedAction(AiActionType.Translate);
return;
}
if (ReferenceEquals(ActionSelector.SelectedItem, RewriteSegment))
{
ApplySelectedAction(AiActionType.Rewrite);
return;
}
if (ReferenceEquals(ActionSelector.SelectedItem, SummarizeSegment))
{
ApplySelectedAction(AiActionType.Summarize);
_ = ExecuteAiActionAsync(AiActionType.Summarize);
}
}
private void TranslateLanguageComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (TranslateLanguageComboBox.SelectedItem is AiTranslateLanguageOption option)
{
SelectedTranslateLanguageOption = option;
}
}
private void RewriteModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (RewriteModeComboBox.SelectedItem is AiRewriteModeOption option)
{
SelectedRewriteModeOption = option;
UpdateRewriteOptionState();
}
}
private void UpdateRewriteOptionState()
{
var isCustom = SelectedRewriteModeOption?.IsCustom ?? false;
RewriteDescriptionTextBlock.Text = SelectedRewriteModeOption?.Description ?? string.Empty;
RewriteDescriptionTextBlock.Visibility = string.IsNullOrWhiteSpace(RewriteDescriptionTextBlock.Text) ? Visibility.Collapsed : Visibility.Visible;
CustomRewriteTextBox.Visibility = isCustom ? Visibility.Visible : Visibility.Collapsed;
}
private async void RunTranslateButton_Click(object sender, RoutedEventArgs e)
{
await ExecuteAiActionAsync(AiActionType.Translate);
}
private async void RunRewriteButton_Click(object sender, RoutedEventArgs e)
{
await ExecuteAiActionAsync(AiActionType.Rewrite);
}
private async Task ExecuteAiActionAsync(AiActionType action)
{
if (_isBusy)
{
_dialogService.InfoBarMessage(Translator.Composer_AiBusyTitle, Translator.Composer_AiBusyMessage, InfoBarMessageType.Warning);
return;
}
if (HtmlHost == null)
{
return;
}
CancelAndDisposeActionCancellationToken();
_actionCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _actionCancellationTokenSource.Token;
SetBusyUi(isBusy: true, showLoading: false);
try
{
var html = await HtmlHost.GetCurrentHtmlAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(html))
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_AiHtmlEmpty, InfoBarMessageType.Error);
return;
}
if (action == AiActionType.Translate && SelectedTranslateLanguageOption == null)
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_ValidationFailed, InfoBarMessageType.Error);
return;
}
if (action == AiActionType.Rewrite && string.IsNullOrWhiteSpace(ResolveRewriteMode()))
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, Translator.WinoAccount_Error_ValidationFailed, InfoBarMessageType.Error);
return;
}
var response = action switch
{
AiActionType.Translate => await _profileService.TranslateAsync(html, SelectedTranslateLanguageOption?.Code ?? string.Empty, cancellationToken).ConfigureAwait(true),
AiActionType.Rewrite => await _profileService.RewriteAsync(html, ResolveRewriteMode(), cancellationToken).ConfigureAwait(true),
AiActionType.Summarize => await _profileService.SummarizeAsync(html, cancellationToken).ConfigureAwait(true),
_ => ApiEnvelope<AiTextResultDto>.Failure(ApiErrorCodes.ValidationFailed)
};
cancellationToken.ThrowIfCancellationRequested();
if (!response.IsSuccess || response.Result == null || string.IsNullOrWhiteSpace(response.Result.Html))
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, WinoAccountAiErrorTranslator.Format(response.ErrorCode, null), InfoBarMessageType.Error);
return;
}
await HtmlHost.ApplyHtmlResultAsync(response.Result.Html, cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_dialogService.InfoBarMessage(Translator.Composer_AiErrorTitle, WinoAccountAiErrorTranslator.Format(null, ex.Message), InfoBarMessageType.Error);
}
finally
{
SetBusyUi(isBusy: false, showLoading: false);
if (_actionCancellationTokenSource != null)
{
_actionCancellationTokenSource.Dispose();
_actionCancellationTokenSource = null;
}
if (action == AiActionType.Summarize)
{
var fallbackAction = _lastConfigurableAction != AiActionType.None && HasAction(_lastConfigurableAction)
? _lastConfigurableAction
: SelectDefaultAction();
ApplySelectedAction(fallbackAction);
}
}
}
private string ResolveRewriteMode()
{
if (SelectedRewriteModeOption == null)
{
return string.Empty;
}
if (!SelectedRewriteModeOption.IsCustom)
{
return SelectedRewriteModeOption.Mode;
}
return CustomRewriteTextBox.Text?.Trim() ?? string.Empty;
}
private void CancelAndDisposeActionCancellationToken()
{
if (_actionCancellationTokenSource == null)
{
return;
}
_actionCancellationTokenSource.Cancel();
_actionCancellationTokenSource.Dispose();
_actionCancellationTokenSource = null;
}
}
@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
namespace Wino.Mail.WinUI.Controls;
public interface IAiHtmlActionHost
{
Task<string?> GetCurrentHtmlAsync(CancellationToken cancellationToken);
Task ApplyHtmlResultAsync(string html, CancellationToken cancellationToken);
Task<string?> TryGetCachedTranslationHtmlAsync(string languageCode, CancellationToken cancellationToken);
Task SaveCachedTranslationHtmlAsync(string languageCode, string html, CancellationToken cancellationToken);
}
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Ai;
namespace Wino.Mail.WinUI.Services;
public sealed class AiActionOptionsService : IAiActionOptionsService
{
public IReadOnlyList<AiTranslateLanguageOption> GetTranslateLanguageOptions()
{
return
[
new("en-US", Translator.Composer_AiTranslateLanguageEnglish),
new("tr-TR", Translator.Composer_AiTranslateLanguageTurkish),
new("de-DE", Translator.Composer_AiTranslateLanguageGerman),
new("fr-FR", Translator.Composer_AiTranslateLanguageFrench),
new("es-ES", Translator.Composer_AiTranslateLanguageSpanish),
new("it-IT", Translator.Composer_AiTranslateLanguageItalian),
new("pt-BR", Translator.Composer_AiTranslateLanguagePortugueseBrazil),
new("nl-NL", Translator.Composer_AiTranslateLanguageDutch),
new("pl-PL", Translator.Composer_AiTranslateLanguagePolish),
new("ru-RU", Translator.Composer_AiTranslateLanguageRussian),
new("ja-JP", Translator.Composer_AiTranslateLanguageJapanese),
new("ko-KR", Translator.Composer_AiTranslateLanguageKorean),
new("zh-CN", Translator.Composer_AiTranslateLanguageChineseSimplified),
new("ar-SA", Translator.Composer_AiTranslateLanguageArabic),
new("hi-IN", Translator.Composer_AiTranslateLanguageHindi),
];
}
public IReadOnlyList<AiRewriteModeOption> GetRewriteModeOptions()
{
return
[
new("polite", Translator.Composer_AiRewritePolite, Translator.Composer_AiRewritePoliteDescription),
new("angry", Translator.Composer_AiRewriteAngry, Translator.Composer_AiRewriteAngryDescription),
new("happy", Translator.Composer_AiRewriteHappy, Translator.Composer_AiRewriteHappyDescription),
new("formal", Translator.Composer_AiRewriteFormal, Translator.Composer_AiRewriteFormalDescription),
new("friendly", Translator.Composer_AiRewriteFriendly, Translator.Composer_AiRewriteFriendlyDescription),
new("shorter", Translator.Composer_AiRewriteShorter, Translator.Composer_AiRewriteShorterDescription),
new("clearer", Translator.Composer_AiRewriteClearer, Translator.Composer_AiRewriteClearerDescription),
new(string.Empty, Translator.Composer_AiRewriteCustom, Translator.Composer_AiRewriteCustomDescription, true),
];
}
}
@@ -0,0 +1,57 @@
using Wino.Core.Domain;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Mail.WinUI.Services;
public static class WinoAccountAiErrorTranslator
{
public static string Translate(string? errorCode)
{
if (string.IsNullOrWhiteSpace(errorCode))
{
return Translator.GeneralTitle_Error;
}
return errorCode switch
{
ApiErrorCodes.AiPackRequired => Translator.WinoAccount_Error_AiPackRequired,
ApiErrorCodes.AiQuotaExceeded => Translator.WinoAccount_Error_AiQuotaExceeded,
ApiErrorCodes.AiHtmlEmpty => Translator.WinoAccount_Error_AiHtmlEmpty,
ApiErrorCodes.AiHtmlTooLarge => Translator.WinoAccount_Error_AiHtmlTooLarge,
ApiErrorCodes.AiUnsupportedLanguage => Translator.WinoAccount_Error_AiUnsupportedLanguage,
ApiErrorCodes.Forbidden => Translator.WinoAccount_Error_Forbidden,
ApiErrorCodes.ValidationFailed => Translator.WinoAccount_Error_ValidationFailed,
_ => errorCode
};
}
public static string Format(string? errorCode, string? errorMessage)
{
var translatedCode = Translate(errorCode);
var hasCode = !string.IsNullOrWhiteSpace(errorCode);
var hasMessage = !string.IsNullOrWhiteSpace(errorMessage);
if (!hasCode && !hasMessage)
{
return Translator.GeneralTitle_Error;
}
var formattedCode = translatedCode;
if (hasCode && !string.Equals(translatedCode, errorCode, System.StringComparison.Ordinal))
{
formattedCode = $"{translatedCode} ({errorCode})";
}
if (!hasMessage || string.Equals(errorMessage, translatedCode, System.StringComparison.OrdinalIgnoreCase) || string.Equals(errorMessage, errorCode, System.StringComparison.OrdinalIgnoreCase))
{
return formattedCode;
}
if (string.IsNullOrWhiteSpace(formattedCode))
{
return errorMessage!;
}
return $"{formattedCode}{System.Environment.NewLine}{errorMessage}";
}
}
+22 -1
View File
@@ -160,6 +160,17 @@
Visibility="{x:Bind ViewModel.IsDraftBusy, Mode=OneWay}">
<ProgressRing IsActive="True" />
</AppBarButton>
<AppBarToggleButton
x:Name="ComposeAiActionsToggleButton"
Checked="ComposeAiActionsToggleButton_Checked"
MinWidth="40"
HorizontalContentAlignment="Center"
LabelPosition="Collapsed"
ToolTipService.ToolTip="{x:Bind domain:Translator.Composer_AiActions}">
<AppBarToggleButton.Icon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</AppBarToggleButton.Icon>
</AppBarToggleButton>
<AppBarButton Click="ToggleEditorThemeClicked" ToolTipService.ToolTip="{x:Bind GetEditorThemeToolTip(WebViewEditor.IsEditorDarkMode), Mode=OneWay}">
<AppBarButton.Icon>
<coreControls:WinoFontIcon Icon="{x:Bind GetEditorThemeIcon(WebViewEditor.IsEditorDarkMode), Mode=OneWay}" />
@@ -307,6 +318,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
@@ -445,10 +457,19 @@
BorderThickness="0"
Text="{x:Bind ViewModel.Subject, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<coreControls:AiActionsPanel
x:Name="ComposeAiActionsPanel"
Grid.Row="5"
Grid.ColumnSpan="2"
Margin="0,8,0,0"
AvailableActions="Rewrite"
HtmlHost="{x:Bind}"
Visibility="{x:Bind GetAiActionsPanelVisibility(ComposeAiActionsToggleButton.IsChecked), Mode=OneWay}" />
<!-- Attachments -->
<ListView
x:Name="AttachmentsListView"
Grid.Row="5"
Grid.Row="6"
Grid.ColumnSpan="2"
ui:ListViewExtensions.Command="{x:Bind ViewModel.OpenAttachmentCommand}"
x:Load="{x:Bind helpers:XamlHelpers.CountToBooleanConverter(ViewModel.IncludedAttachments.Count), Mode=OneWay}"
@@ -32,12 +32,15 @@ using Wino.Views.Abstract;
namespace Wino.Views.Mail;
public sealed partial class ComposePage : ComposePageAbstract,
IAiHtmlActionHost,
IRecipient<CreateNewComposeMailRequested>,
IRecipient<ApplicationThemeChanged>,
IRecipient<ReaderItemRefreshRequestedEvent>
{
public WebView2 GetWebView() => WebViewEditor.GetUnderlyingWebView();
public Visibility GetAiActionsPanelVisibility(bool? isChecked) => isChecked == true ? Visibility.Visible : Visibility.Collapsed;
private readonly List<IDisposable> _disposables = [];
public ComposePage()
@@ -283,6 +286,11 @@ public sealed partial class ComposePage : ComposePageAbstract,
ViewModel.IsCCBCCVisible = true;
}
private async void ComposeAiActionsToggleButton_Checked(object sender, RoutedEventArgs e)
{
await ComposeAiActionsPanel.RefreshAvailabilityAsync();
}
private async void TokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArgs args)
{
// Check is valid email.
@@ -410,11 +418,39 @@ public sealed partial class ComposePage : ComposePageAbstract,
base.OnNavigatingFrom(e);
FocusManager.GotFocus -= GlobalFocusManagerGotFocus;
ComposeAiActionsPanel.CancelPendingOperation();
await ViewModel.UpdateMimeChangesAsync();
DisposeDisposables();
}
public async Task<string?> GetCurrentHtmlAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var html = await WebViewEditor.GetHtmlBodyAsync();
cancellationToken.ThrowIfCancellationRequested();
return html;
}
public async Task ApplyHtmlResultAsync(string html, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await WebViewEditor.RenderHtmlAsync(html);
cancellationToken.ThrowIfCancellationRequested();
}
public Task<string?> TryGetCachedTranslationHtmlAsync(string languageCode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<string?>(null);
}
public Task SaveCachedTranslationHtmlAsync(string languageCode, string html, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
private void OpenAttachment_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.CommandParameter is MailAttachmentViewModel attachment)
@@ -13,6 +13,7 @@
xmlns:local="using:Wino.Behaviors"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:viewModelData="using:Wino.Mail.ViewModels.Data"
x:Name="root"
@@ -181,6 +182,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="5,0">
<Grid.ColumnDefinitions>
@@ -273,13 +275,16 @@
IsDynamicOverflowEnabled="True"
OverflowButtonVisibility="Auto">
<interactivity:Interaction.Behaviors>
<local:BindableCommandBarBehavior ItemClickedCommand="{x:Bind ViewModel.OperationClickedCommand}" PrimaryCommands="{x:Bind ViewModel.MenuItems, Mode=OneWay}" />
<local:BindableCommandBarBehavior
ItemClickedCommand="{x:Bind ViewModel.OperationClickedCommand}"
PrimaryCommands="{x:Bind ViewModel.MenuItems, Mode=OneWay}" />
</interactivity:Interaction.Behaviors>
<CommandBar.Content>
<Grid Padding="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -317,6 +322,19 @@
<TextBlock FontSize="12" Text="{x:Bind helpers:XamlHelpers.GetCreationDateString(ViewModel.CreationDate, ViewModel.PreferencesService.Prefer24HourTimeFormat), Mode=OneWay}" />
</StackPanel>
</Grid>
<AppBarToggleButton
x:Name="ReaderAiActionsToggleButton"
Grid.Column="2"
Checked="ReaderAiActionsToggleButton_Checked"
MinWidth="40"
HorizontalContentAlignment="Center"
LabelPosition="Collapsed"
ToolTipService.ToolTip="{x:Bind domain:Translator.Composer_AiActions}">
<AppBarToggleButton.Icon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</AppBarToggleButton.Icon>
</AppBarToggleButton>
</Grid>
</CommandBar.Content>
</CommandBar>
@@ -409,8 +427,16 @@
</Grid>
</ScrollViewer>
<coreControls:AiActionsPanel
x:Name="ReaderAiActionsPanel"
Grid.Row="3"
Margin="0,8,0,0"
AvailableActions="Translate, Summarize"
HtmlHost="{x:Bind}"
Visibility="{x:Bind GetAiActionsPanelVisibility(ReaderAiActionsToggleButton.IsChecked), Mode=OneWay}" />
<!-- Attachments -->
<Grid Grid.Row="3">
<Grid Grid.Row="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -442,7 +468,7 @@
<InfoBar
x:Name="ImageLoadingDisabledMessage"
Grid.Row="4"
Grid.Row="5"
HorizontalContentAlignment="Stretch"
x:Load="{x:Bind ViewModel.IsImageRenderingDisabled, Mode=OneWay}"
IsOpen="True"
@@ -458,7 +484,7 @@
<ProgressBar
x:Name="DownloadingProgressBar"
Grid.Row="3"
Grid.Row="4"
Margin="12,1"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
@@ -16,6 +17,7 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Printing;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.WinUI;
using Wino.Mail.WinUI.Controls;
using Wino.Mail.WinUI.Extensions;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.Client.Shell;
@@ -24,19 +26,24 @@ using Wino.Views.Abstract;
namespace Wino.Views.Mail;
public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
IAiHtmlActionHost,
IRecipient<HtmlRenderingRequested>,
IRecipient<CancelRenderingContentRequested>,
IRecipient<ApplicationThemeChanged>
{
private readonly IPreferencesService _preferencesService = App.Current.Services.GetService<IPreferencesService>()!;
private readonly IMailDialogService _dialogService = App.Current.Services.GetService<IMailDialogService>()!;
private readonly IMimeFileService _mimeFileService = App.Current.Services.GetRequiredService<IMimeFileService>();
private bool isRenderingInProgress = false;
private bool? _lastAppliedDarkTheme;
private TaskCompletionSource<bool> DOMLoadedTask = new TaskCompletionSource<bool>();
private string _currentRenderedHtml = string.Empty;
public WebView2 GetWebView() => Chromium;
public Visibility GetAiActionsPanelVisibility(bool? isChecked) => isChecked == true ? Visibility.Visible : Visibility.Collapsed;
public MailRenderingPage()
{
InitializeComponent();
@@ -82,6 +89,7 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
private async Task RenderInternalAsync(string htmlBody)
{
isRenderingInProgress = true;
_currentRenderedHtml = htmlBody ?? string.Empty;
await DOMLoadedTask.Task;
@@ -141,10 +149,63 @@ public sealed partial class MailRenderingPage : MailRenderingPageAbstract,
ViewModel.SaveHTMLasPDFFunc = null;
ViewModel.DirectPrintFuncAsync = null;
_currentRenderedHtml = string.Empty;
ReaderAiActionsPanel.CancelPendingOperation();
DisposeWebView2();
}
public Task<string?> GetCurrentHtmlAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<string?>(_currentRenderedHtml);
}
public async Task ApplyHtmlResultAsync(string html, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await RenderInternalAsync(html);
cancellationToken.ThrowIfCancellationRequested();
}
private async void ReaderAiActionsToggleButton_Checked(object sender, RoutedEventArgs e)
{
await ReaderAiActionsPanel.RefreshAvailabilityAsync();
}
public async Task<string?> TryGetCachedTranslationHtmlAsync(string languageCode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!ViewModel.CurrentMailAccountId.HasValue || !ViewModel.CurrentMailFileId.HasValue || string.IsNullOrWhiteSpace(languageCode))
{
return null;
}
return await _mimeFileService.GetTranslatedHtmlAsync(
ViewModel.CurrentMailAccountId.Value,
ViewModel.CurrentMailFileId.Value,
languageCode,
cancellationToken).ConfigureAwait(false);
}
public async Task SaveCachedTranslationHtmlAsync(string languageCode, string html, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!ViewModel.CurrentMailAccountId.HasValue || !ViewModel.CurrentMailFileId.HasValue || string.IsNullOrWhiteSpace(languageCode))
{
return;
}
await _mimeFileService.SaveTranslatedHtmlAsync(
ViewModel.CurrentMailAccountId.Value,
ViewModel.CurrentMailFileId.Value,
languageCode,
html,
cancellationToken).ConfigureAwait(false);
}
private void DisposeWebView2()
{
if (Chromium == null) return;