From 3bcb26ddd7be243d313779e923df5385ef926e28 Mon Sep 17 00:00:00 2001 From: Casezy Date: Mon, 8 Jun 2026 14:00:10 +0700 Subject: [PATCH 1/2] feat: Add OpenRouter, Deepseek, Gemini translation support & Model validation --- .../Configuration/TranslationConfiguration.cs | 42 +- .../Deepseek/DeepseekContainer.cs | 37 ++ .../Deepseek/DeepseekTranslator.cs | 68 +++ .../Gemini/GeminiContainer.cs | 33 ++ .../Gemini/GeminiTranslator.cs | 76 ++++ .../Openrouter/OpenrouterContainer.cs | 39 ++ .../Openrouter/OpenrouterTranslator.cs | 72 +++ .../TranslatorFactory.cs | 11 +- src/Translumo.Translation/Translators.cs | 10 +- .../ViewModels/LanguagesSettingsViewModel.cs | 411 +++++++++++++----- .../MVVM/Views/LanguagesSettingsView.xaml | 102 ++++- 11 files changed, 773 insertions(+), 128 deletions(-) create mode 100644 src/Translumo.Translation/Deepseek/DeepseekContainer.cs create mode 100644 src/Translumo.Translation/Deepseek/DeepseekTranslator.cs create mode 100644 src/Translumo.Translation/Gemini/GeminiContainer.cs create mode 100644 src/Translumo.Translation/Gemini/GeminiTranslator.cs create mode 100644 src/Translumo.Translation/Openrouter/OpenrouterContainer.cs create mode 100644 src/Translumo.Translation/Openrouter/OpenrouterTranslator.cs diff --git a/src/Translumo.Translation/Configuration/TranslationConfiguration.cs b/src/Translumo.Translation/Configuration/TranslationConfiguration.cs index d818cb5f..dd52f6e1 100644 --- a/src/Translumo.Translation/Configuration/TranslationConfiguration.cs +++ b/src/Translumo.Translation/Configuration/TranslationConfiguration.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Translumo.Infrastructure.Language; using Translumo.Utils; @@ -40,6 +40,42 @@ public Translators Translator } } + public string DeepseekApiKey + { + get => _deepseekApiKey; + set + { + SetProperty(ref _deepseekApiKey, value); + } + } + + public string GeminiApiKey + { + get => _geminiApiKey; + set + { + SetProperty(ref _geminiApiKey, value); + } + } + + public string OpenrouterApiKey + { + get => _openrouterApiKey; + set + { + SetProperty(ref _openrouterApiKey, value); + } + } + + public string OpenrouterModel + { + get => _openrouterModel; + set + { + SetProperty(ref _openrouterModel, value); + } + } + public List ProxySettings { get => _proxySettings; @@ -52,6 +88,10 @@ public List ProxySettings private Languages _translateFromLang; private Languages _translateToLang; private Translators _translator; + private string _deepseekApiKey; + private string _geminiApiKey; + private string _openrouterApiKey; + private string _openrouterModel = "nvidia/nemotron-3.5-content-safety:free"; private List _proxySettings = new List(); } } diff --git a/src/Translumo.Translation/Deepseek/DeepseekContainer.cs b/src/Translumo.Translation/Deepseek/DeepseekContainer.cs new file mode 100644 index 00000000..dcd49469 --- /dev/null +++ b/src/Translumo.Translation/Deepseek/DeepseekContainer.cs @@ -0,0 +1,37 @@ +using System.Net; +using Translumo.Translation.Configuration; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Deepseek +{ + public sealed class DeepseekContainer : TranslationContainer + { + public HttpReader Reader { get; private set; } + public string ApiKey { get; private set; } + + public DeepseekContainer(string apiKey, Proxy proxy = null, bool isPrimary = false) : base(proxy, isPrimary) + { + ApiKey = apiKey; + Reader = CreateReader(proxy); + } + + public override void Reset() + { + base.Reset(); + Reader.Cookies = new CookieContainer(); + } + + private HttpReader CreateReader(Proxy proxy) + { + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + if (!string.IsNullOrEmpty(ApiKey)) + { + reader.OptionalHeaders.Add("Authorization", $"Bearer {ApiKey}"); + } + reader.Proxy = proxy?.ToWebProxy(); + return reader; + } + } +} diff --git a/src/Translumo.Translation/Deepseek/DeepseekTranslator.cs b/src/Translumo.Translation/Deepseek/DeepseekTranslator.cs new file mode 100644 index 00000000..176b2326 --- /dev/null +++ b/src/Translumo.Translation/Deepseek/DeepseekTranslator.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Translumo.Infrastructure.Language; +using Translumo.Translation.Configuration; +using Translumo.Translation.Exceptions; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Deepseek +{ + public sealed class DeepseekTranslator : BaseTranslator + { + private const string API_URL = "https://api.deepseek.com/chat/completions"; + + public DeepseekTranslator(TranslationConfiguration translationConfiguration, LanguageService languageService, ILogger logger) + : base(translationConfiguration, languageService, logger) + { + } + + protected override async Task TranslateTextInternal(DeepseekContainer container, string sourceText) + { + if (string.IsNullOrEmpty(container.ApiKey)) + { + throw new TranslationException("Deepseek API Key is not configured."); + } + + var payload = new + { + model = "deepseek-chat", + messages = new[] + { + new { role = "system", content = $"You are a highly accurate translator. Translate the following text from {SourceLangDescriptor.IsoCode} to {TargetLangDescriptor.IsoCode}. Reply ONLY with the translation, no extra text, no explanations." }, + new { role = "user", content = sourceText } + }, + temperature = 0.3 + }; + + var dataIn = JsonSerializer.Serialize(payload); + HttpResponse httpResponse = await container.Reader.RequestWebDataAsync(API_URL, HttpMethods.POST, dataIn, acceptCookie: false).ConfigureAwait(false); + + if (httpResponse.IsSuccessful) + { + try + { + using var doc = JsonDocument.Parse(httpResponse.Body); + var translation = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString(); + return translation.Trim(); + } + catch (Exception ex) + { + throw new TranslationException($"Failed to parse Deepseek response: '{httpResponse.Body}'", ex); + } + } + + throw new TranslationException($"Deepseek API returned error: '{httpResponse.Body}'", httpResponse.InnerException); + } + + protected override IList CreateContainers(TranslationConfiguration configuration) + { + var result = configuration.ProxySettings.Select(proxy => new DeepseekContainer(configuration.DeepseekApiKey, proxy)).ToList(); + result.Add(new DeepseekContainer(configuration.DeepseekApiKey, isPrimary: true)); + return result; + } + } +} diff --git a/src/Translumo.Translation/Gemini/GeminiContainer.cs b/src/Translumo.Translation/Gemini/GeminiContainer.cs new file mode 100644 index 00000000..3555b360 --- /dev/null +++ b/src/Translumo.Translation/Gemini/GeminiContainer.cs @@ -0,0 +1,33 @@ +using System.Net; +using Translumo.Translation.Configuration; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Gemini +{ + public sealed class GeminiContainer : TranslationContainer + { + public HttpReader Reader { get; private set; } + public string ApiKey { get; private set; } + + public GeminiContainer(string apiKey, Proxy proxy = null, bool isPrimary = false) : base(proxy, isPrimary) + { + ApiKey = apiKey; + Reader = CreateReader(proxy); + } + + public override void Reset() + { + base.Reset(); + Reader.Cookies = new CookieContainer(); + } + + private HttpReader CreateReader(Proxy proxy) + { + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + reader.Proxy = proxy?.ToWebProxy(); + return reader; + } + } +} diff --git a/src/Translumo.Translation/Gemini/GeminiTranslator.cs b/src/Translumo.Translation/Gemini/GeminiTranslator.cs new file mode 100644 index 00000000..c9c94023 --- /dev/null +++ b/src/Translumo.Translation/Gemini/GeminiTranslator.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Translumo.Infrastructure.Language; +using Translumo.Translation.Configuration; +using Translumo.Translation.Exceptions; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Gemini +{ + public sealed class GeminiTranslator : BaseTranslator + { + private const string API_URL_TEMPLATE = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={0}"; + + public GeminiTranslator(TranslationConfiguration translationConfiguration, LanguageService languageService, ILogger logger) + : base(translationConfiguration, languageService, logger) + { + } + + protected override async Task TranslateTextInternal(GeminiContainer container, string sourceText) + { + if (string.IsNullOrEmpty(container.ApiKey)) + { + throw new TranslationException("Gemini API Key is not configured."); + } + + var payload = new + { + contents = new[] + { + new + { + parts = new[] + { + new { text = $"You are a highly accurate translator. Translate the following text from {SourceLangDescriptor.IsoCode} to {TargetLangDescriptor.IsoCode}. Reply ONLY with the translation, no extra text, no explanations.\n\nText: {sourceText}" } + } + } + } + }; + + var dataIn = JsonSerializer.Serialize(payload); + var apiUrl = string.Format(API_URL_TEMPLATE, container.ApiKey); + HttpResponse httpResponse = await container.Reader.RequestWebDataAsync(apiUrl, HttpMethods.POST, dataIn, acceptCookie: false).ConfigureAwait(false); + + if (httpResponse.IsSuccessful) + { + try + { + using var doc = JsonDocument.Parse(httpResponse.Body); + var candidates = doc.RootElement.GetProperty("candidates"); + if (candidates.GetArrayLength() > 0) + { + var translation = candidates[0].GetProperty("content").GetProperty("parts")[0].GetProperty("text").GetString(); + return translation.Trim(); + } + } + catch (Exception ex) + { + throw new TranslationException($"Failed to parse Gemini response: '{httpResponse.Body}'", ex); + } + } + + throw new TranslationException($"Gemini API returned error: '{httpResponse.Body}'", httpResponse.InnerException); + } + + protected override IList CreateContainers(TranslationConfiguration configuration) + { + var result = configuration.ProxySettings.Select(proxy => new GeminiContainer(configuration.GeminiApiKey, proxy)).ToList(); + result.Add(new GeminiContainer(configuration.GeminiApiKey, isPrimary: true)); + return result; + } + } +} diff --git a/src/Translumo.Translation/Openrouter/OpenrouterContainer.cs b/src/Translumo.Translation/Openrouter/OpenrouterContainer.cs new file mode 100644 index 00000000..ca793705 --- /dev/null +++ b/src/Translumo.Translation/Openrouter/OpenrouterContainer.cs @@ -0,0 +1,39 @@ +using System.Net; +using Translumo.Translation.Configuration; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Openrouter +{ + public sealed class OpenrouterContainer : TranslationContainer + { + public HttpReader Reader { get; private set; } + public string ApiKey { get; private set; } + + public OpenrouterContainer(string apiKey, Proxy proxy = null, bool isPrimary = false) : base(proxy, isPrimary) + { + ApiKey = apiKey; + Reader = CreateReader(proxy); + } + + public override void Reset() + { + base.Reset(); + Reader.Cookies = new CookieContainer(); + } + + private HttpReader CreateReader(Proxy proxy) + { + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + if (!string.IsNullOrEmpty(ApiKey)) + { + reader.OptionalHeaders.Add("Authorization", $"Bearer {ApiKey}"); + } + reader.OptionalHeaders.Add("HTTP-Referer", "https://github.com/Translumo"); + reader.OptionalHeaders.Add("X-Title", "Translumo"); + reader.Proxy = proxy?.ToWebProxy(); + return reader; + } + } +} diff --git a/src/Translumo.Translation/Openrouter/OpenrouterTranslator.cs b/src/Translumo.Translation/Openrouter/OpenrouterTranslator.cs new file mode 100644 index 00000000..91f82288 --- /dev/null +++ b/src/Translumo.Translation/Openrouter/OpenrouterTranslator.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Translumo.Infrastructure.Language; +using Translumo.Translation.Configuration; +using Translumo.Translation.Exceptions; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Openrouter +{ + public sealed class OpenrouterTranslator : BaseTranslator + { + private const string API_URL = "https://openrouter.ai/api/v1/chat/completions"; + + public OpenrouterTranslator(TranslationConfiguration translationConfiguration, LanguageService languageService, ILogger logger) + : base(translationConfiguration, languageService, logger) + { + } + + protected override async Task TranslateTextInternal(OpenrouterContainer container, string sourceText) + { + if (string.IsNullOrEmpty(container.ApiKey)) + { + throw new TranslationException("Openrouter API Key is not configured."); + } + + var modelName = !string.IsNullOrWhiteSpace(TranslationConfiguration.OpenrouterModel) + ? TranslationConfiguration.OpenrouterModel + : "nvidia/nemotron-3.5-content-safety:free"; + + var payload = new + { + model = modelName, + messages = new[] + { + new { role = "system", content = $"You are a highly accurate translator. Translate the following text from {SourceLangDescriptor.IsoCode} to {TargetLangDescriptor.IsoCode}. Reply ONLY with the translation, no extra text, no explanations." }, + new { role = "user", content = sourceText } + }, + temperature = 0.3 + }; + + var dataIn = JsonSerializer.Serialize(payload); + HttpResponse httpResponse = await container.Reader.RequestWebDataAsync(API_URL, HttpMethods.POST, dataIn, acceptCookie: false).ConfigureAwait(false); + + if (httpResponse.IsSuccessful) + { + try + { + using var doc = JsonDocument.Parse(httpResponse.Body); + var translation = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString(); + return translation.Trim(); + } + catch (Exception ex) + { + throw new TranslationException($"Failed to parse Openrouter response: '{httpResponse.Body}'", ex); + } + } + + throw new TranslationException($"Openrouter API returned error: '{httpResponse.Body}'", httpResponse.InnerException); + } + + protected override IList CreateContainers(TranslationConfiguration configuration) + { + var result = configuration.ProxySettings.Select(proxy => new OpenrouterContainer(configuration.OpenrouterApiKey, proxy)).ToList(); + result.Add(new OpenrouterContainer(configuration.OpenrouterApiKey, isPrimary: true)); + return result; + } + } +} diff --git a/src/Translumo.Translation/TranslatorFactory.cs b/src/Translumo.Translation/TranslatorFactory.cs index c32aab6b..054f2d25 100644 --- a/src/Translumo.Translation/TranslatorFactory.cs +++ b/src/Translumo.Translation/TranslatorFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.Extensions.Logging; using Translumo.Infrastructure.Dispatching; using Translumo.Infrastructure.Language; @@ -7,6 +7,9 @@ using Translumo.Translation.Google; using Translumo.Translation.Papago; using Translumo.Translation.Yandex; +using Translumo.Translation.Deepseek; +using Translumo.Translation.Gemini; +using Translumo.Translation.Openrouter; namespace Translumo.Translation { @@ -35,6 +38,12 @@ public ITranslator CreateTranslator(TranslationConfiguration translatorConfigura return new PapagoTranslator(translatorConfiguration, _languageService, _logger); case Translators.Google: return new GoogleTranslator(translatorConfiguration, _languageService, _logger); + case Translators.Deepseek: + return new DeepseekTranslator(translatorConfiguration, _languageService, _logger); + case Translators.Gemini: + return new GeminiTranslator(translatorConfiguration, _languageService, _logger); + case Translators.Openrouter: + return new OpenrouterTranslator(translatorConfiguration, _languageService, _logger); default: throw new NotSupportedException(); } diff --git a/src/Translumo.Translation/Translators.cs b/src/Translumo.Translation/Translators.cs index 39b07657..228c9e24 100644 --- a/src/Translumo.Translation/Translators.cs +++ b/src/Translumo.Translation/Translators.cs @@ -1,4 +1,4 @@ - + namespace Translumo.Translation { public enum Translators : byte @@ -9,6 +9,12 @@ public enum Translators : byte Google = 2, - Papago = 3 + Papago = 3, + + Deepseek = 4, + + Gemini = 5, + + Openrouter = 6 } } diff --git a/src/Translumo/MVVM/ViewModels/LanguagesSettingsViewModel.cs b/src/Translumo/MVVM/ViewModels/LanguagesSettingsViewModel.cs index ccb6b409..d0a64af2 100644 --- a/src/Translumo/MVVM/ViewModels/LanguagesSettingsViewModel.cs +++ b/src/Translumo/MVVM/ViewModels/LanguagesSettingsViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Toolkit.Mvvm.Input; using OpenCvSharp; using Serilog.Core; @@ -7,7 +7,7 @@ using System.Collections.ObjectModel; using System.Globalization; using System.Linq; -using System.Speech.Synthesis; +using System.Text.Json; using System.Threading.Tasks; using System.Windows.Input; using Translumo.Dialog; @@ -17,10 +17,12 @@ using Translumo.MVVM.Models; using Translumo.OCR.Configuration; using Translumo.OCR.WindowsOCR; +using Translumo.Translation; using Translumo.Translation.Configuration; using Translumo.TTS; using Translumo.Utils; using Translumo.Utils.Extensions; +using Translumo.Utils.Http; using RelayCommand = Microsoft.Toolkit.Mvvm.Input.RelayCommand; namespace Translumo.MVVM.ViewModels @@ -37,35 +39,99 @@ public sealed class LanguagesSettingsViewModel : BindableBase, IAdditionalPanelC public TtsConfiguration TtsSettings { get; set; } - private ObservableCollection _availableVoices; - public ObservableCollection AvailableVoices + public bool IsApiKeyRequired => SelectedTranslator == Translators.Deepseek || SelectedTranslator == Translators.Gemini || SelectedTranslator == Translators.Openrouter; + + public bool IsOpenrouterSelected => SelectedTranslator == Translators.Openrouter; + + public string OpenrouterModel + { + get => Model.OpenrouterModel; + set + { + Model.OpenrouterModel = value; + OnPropertyChanged(nameof(OpenrouterModel)); + } + } + + public Translators SelectedTranslator + { + get => Model.Translator; + set + { + Model.Translator = value; + OnPropertyChanged(nameof(SelectedTranslator)); + OnPropertyChanged(nameof(SelectedTranslatorIndex)); + OnPropertyChanged(nameof(IsApiKeyRequired)); + OnPropertyChanged(nameof(IsOpenrouterSelected)); + OnPropertyChanged(nameof(CurrentApiKey)); + OnPropertyChanged(nameof(CurrentApiKeyName)); + } + } + + public int SelectedTranslatorIndex { - get => _availableVoices; - set => SetProperty(ref _availableVoices, value); + get => (int)SelectedTranslator; + set => SelectedTranslator = (Translators)value; + } + + public string CurrentApiKeyName + { + get => SelectedTranslator switch + { + Translators.Deepseek => "Deepseek API Key", + Translators.Gemini => "Gemini API Key", + Translators.Openrouter => "OpenRouter API Key", + _ => string.Empty + }; } - private VoiceInfo _selectedVoice; - public VoiceInfo SelectedVoice + public string CurrentApiKey { - get => _selectedVoice; + get + { + return SelectedTranslator switch + { + Translators.Deepseek => Model.DeepseekApiKey, + Translators.Gemini => Model.GeminiApiKey, + Translators.Openrouter => Model.OpenrouterApiKey, + _ => string.Empty + }; + } set { - SetProperty(ref _selectedVoice, value); - if (value != null) + switch (SelectedTranslator) { - Action updateVoiceAction = () => - { - TtsSettings.SelectedVoiceName = value.Name; - }; - - _ = ReconfigureTts(TtsSettings.TtsLanguage, TtsSettings.TtsSystem, updateVoiceAction); + case Translators.Deepseek: Model.DeepseekApiKey = value; break; + case Translators.Gemini: Model.GeminiApiKey = value; break; + case Translators.Openrouter: Model.OpenrouterApiKey = value; break; } + OnPropertyChanged(nameof(CurrentApiKey)); } } - public bool IsTtsWindowsSelected => TtsSettings.TtsSystem == TTSEngines.WindowsTTS; + public string ApiKeyValidationStatus + { + get => _apiKeyValidationStatus; + set => SetProperty(ref _apiKeyValidationStatus, value); + } + + public bool IsValidating + { + get => _isValidating; + set => SetProperty(ref _isValidating, value); + } + + public string OpenrouterModelValidationStatus + { + get => _openrouterModelValidationStatus; + set => SetProperty(ref _openrouterModelValidationStatus, value); + } - public bool IsTtsEnabled => TtsSettings.TtsSystem != TTSEngines.None; + public bool IsValidatingModel + { + get => _isValidatingModel; + set => SetProperty(ref _isValidatingModel, value); + } public ObservableCollection ProxyCollection @@ -117,9 +183,15 @@ public TTSEngines TtsSystem public ICommand ProxyItemDeletedCommand => new RelayCommand(OnProxyItemDeletedCommand); public ICommand ProxyItemAddCommand => new RelayCommand(OnProxyItemAddCommand); public ICommand ProxySettingsSubmitCommand => new RelayCommand(OnProxySettingsSubmit); + public ICommand ValidateApiKeyCommand => new AsyncRelayCommand(OnValidateApiKeyAsync); + public ICommand ValidateOpenrouterModelCommand => new AsyncRelayCommand(OnValidateOpenrouterModelAsync); private ObservableCollection _proxyCollection; private bool _proxySettingsIsOpened; + private string _apiKeyValidationStatus; + private bool _isValidating; + private string _openrouterModelValidationStatus; + private bool _isValidatingModel; private readonly DialogService _dialogService; private readonly OcrGeneralConfiguration _ocrConfiguration; @@ -148,13 +220,6 @@ public LanguagesSettingsViewModel(LanguageService languageService, TranslationCo this.TtsSettings = ttsConfiguration; this.TtsSettings.TtsLanguage = this.Model.TranslateToLang; - this.AvailableVoices = new ObservableCollection(); - - if (this.TtsSettings.TtsSystem == TTSEngines.WindowsTTS) - { - var languageCode = languageService.GetLanguageDescriptor(this.TtsSettings.TtsLanguage).Code; - LoadAvailableVoices(languageCode); - } this._languageService = languageService; this._dialogService = dialogService; @@ -162,94 +227,252 @@ public LanguagesSettingsViewModel(LanguageService languageService, TranslationCo this._logger = logger; } - private void LoadAvailableVoices(string languageCode) + private void OnProxySettingsClicked() + { + InitializeProxyCollection(); + ProxySettingsIsOpened = true; + } + + private void OnProxyItemDeletedCommand(ProxyCardItem itemToDelete) + { + _proxyCollection.Remove(itemToDelete); + } + + private void OnProxyItemAddCommand() + { + _proxyCollection.Add(new ProxyCardItem()); + } + + private void OnProxySettingsSubmit(bool applyProxy) + { + if (applyProxy) + { + Model.ProxySettings = ProxyCollection.Where(pr => pr.IsValid()) + .Select(pr => pr.MapTo()) + .ToList(); + } + + ProxySettingsIsOpened = false; + } + + private async Task OnValidateApiKeyAsync() { + if (string.IsNullOrWhiteSpace(CurrentApiKey)) + { + ApiKeyValidationStatus = "✗ API Key is empty."; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + "API Key cannot be empty. Please enter a valid key.", + SimpleDialogTypes.Error, "Validation Failed")); + return; + } + + IsValidating = true; + ApiKeyValidationStatus = "⏳ Validating..."; + try { - var voices = GetAvailableVoicesForLanguage(languageCode); - AvailableVoices = new ObservableCollection(voices); - - if (!string.IsNullOrEmpty(TtsSettings.SelectedVoiceName)) + bool isValid = false; + string errorMessage = null; + + switch (SelectedTranslator) { - _selectedVoice = AvailableVoices.FirstOrDefault(v => - v.Name.Equals(TtsSettings.SelectedVoiceName, StringComparison.OrdinalIgnoreCase)); + case Translators.Deepseek: + (isValid, errorMessage) = await ValidateDeepseekKeyAsync(CurrentApiKey); + break; + case Translators.Gemini: + (isValid, errorMessage) = await ValidateGeminiKeyAsync(CurrentApiKey); + break; + case Translators.Openrouter: + (isValid, errorMessage) = await ValidateOpenrouterKeyAsync(CurrentApiKey); + break; } - - if (_selectedVoice == null && AvailableVoices.Count > 0) + + if (isValid) + { + ApiKeyValidationStatus = "✓ Valid"; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"{CurrentApiKeyName} is valid and ready to use!", + SimpleDialogTypes.Info, "Validation Successful")); + } + else { - _selectedVoice = AvailableVoices[0]; - TtsSettings.SelectedVoiceName = _selectedVoice.Name; + ApiKeyValidationStatus = "✗ Invalid"; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"{CurrentApiKeyName} validation failed: {errorMessage}", + SimpleDialogTypes.Error, "Validation Failed")); } - - OnPropertyChanged(nameof(SelectedVoice)); } catch (Exception ex) { - _logger.LogError(ex, "Load available voices error"); - AvailableVoices = new ObservableCollection(); + ApiKeyValidationStatus = "✗ Error"; + _logger.LogError(ex, "API Key validation failed"); + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"Validation error: {ex.Message}", + SimpleDialogTypes.Error, "Validation Error")); + } + finally + { + IsValidating = false; } } - private List GetAvailableVoicesForLanguage(string languageTag) + private async Task<(bool isValid, string error)> ValidateDeepseekKeyAsync(string apiKey) { - using var synth = new SpeechSynthesizer(); - var result = new List(); - - try + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + reader.ThrowExceptions = false; + reader.OptionalHeaders.Add("Authorization", $"Bearer {apiKey}"); + + var payload = new { - var voices = synth.GetInstalledVoices(new CultureInfo(languageTag)); - if (voices.Count > 0) - { - result.AddRange(voices.Select(v => v.VoiceInfo)); - return result; - } + model = "deepseek-chat", + messages = new[] { new { role = "user", content = "Hi" } }, + max_tokens = 5 + }; + + var response = await reader.RequestWebDataAsync( + "https://api.deepseek.com/chat/completions", + HttpMethods.POST, JsonSerializer.Serialize(payload)); + + if (response.IsSuccessful) + return (true, null); + + return (false, response.Body ?? "Unable to reach Deepseek API. Check your key and network."); + } + + private async Task<(bool isValid, string error)> ValidateGeminiKeyAsync(string apiKey) + { + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + reader.ThrowExceptions = false; + + // Use the models list endpoint — lightweight and proves the key works + var response = await reader.RequestWebDataAsync( + $"https://generativelanguage.googleapis.com/v1beta/models?key={apiKey}", + HttpMethods.GET); + + if (response.IsSuccessful) + return (true, null); + + return (false, response.Body ?? "Unable to reach Gemini API. Check your key and network."); + } + + private async Task<(bool isValid, string error)> ValidateOpenrouterKeyAsync(string apiKey) + { + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + reader.ThrowExceptions = false; + reader.OptionalHeaders.Add("Authorization", $"Bearer {apiKey}"); + + // Use the auth/key endpoint to check credits/validity + var response = await reader.RequestWebDataAsync( + "https://openrouter.ai/api/v1/auth/key", + HttpMethods.GET); + + if (response.IsSuccessful) + return (true, null); + + return (false, response.Body ?? "Unable to reach OpenRouter API. Check your key and network."); + } + + private async Task OnValidateOpenrouterModelAsync() + { + var modelName = OpenrouterModel; + if (string.IsNullOrWhiteSpace(modelName)) + { + OpenrouterModelValidationStatus = "✗ Model name is empty."; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + "Model name cannot be empty. Please enter a valid OpenRouter model identifier.", + SimpleDialogTypes.Error, "Validation Failed")); + return; } - catch + + var apiKey = Model.OpenrouterApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) { + OpenrouterModelValidationStatus = "✗ API Key is required."; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + "OpenRouter API Key is required to validate the model. Please enter your API Key first.", + SimpleDialogTypes.Error, "Validation Failed")); + return; } + IsValidatingModel = true; + OpenrouterModelValidationStatus = "⏳ Validating model..."; + try { - var shortTag = languageTag.Split('-')[0]; - var voices = synth.GetInstalledVoices(new CultureInfo(shortTag)); - if (voices.Count > 0) + var (isValid, errorMessage) = await ValidateOpenrouterModelRequestAsync(apiKey, modelName); + + if (isValid) + { + OpenrouterModelValidationStatus = "✓ Model is valid"; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"Model '{modelName}' is valid and accessible with your API key!", + SimpleDialogTypes.Info, "Model Validation Successful")); + } + else { - result.AddRange(voices.Select(v => v.VoiceInfo)); + OpenrouterModelValidationStatus = "✗ Invalid model"; + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"Model validation failed: {errorMessage}", + SimpleDialogTypes.Error, "Model Validation Failed")); } } - catch + catch (Exception ex) { + OpenrouterModelValidationStatus = "✗ Error"; + _logger.LogError(ex, "OpenRouter model validation failed"); + await _dialogService.ShowDialogAsync(SimpleDialogViewModel.Create( + $"Validation error: {ex.Message}", + SimpleDialogTypes.Error, "Validation Error")); + } + finally + { + IsValidatingModel = false; } - - return result; } - private void OnProxySettingsClicked() + private async Task<(bool isValid, string error)> ValidateOpenrouterModelRequestAsync(string apiKey, string modelName) { - InitializeProxyCollection(); - ProxySettingsIsOpened = true; - } + var reader = new HttpReader(); + reader.ContentType = "application/json"; + reader.Accept = "application/json"; + reader.ThrowExceptions = false; + reader.OptionalHeaders.Add("Authorization", $"Bearer {apiKey}"); - private void OnProxyItemDeletedCommand(ProxyCardItem itemToDelete) - { - _proxyCollection.Remove(itemToDelete); - } + var payload = new + { + model = modelName, + messages = new[] { new { role = "user", content = "Hi" } }, + max_tokens = 1 + }; - private void OnProxyItemAddCommand() - { - _proxyCollection.Add(new ProxyCardItem()); - } + var response = await reader.RequestWebDataAsync( + "https://openrouter.ai/api/v1/chat/completions", + HttpMethods.POST, JsonSerializer.Serialize(payload)); - private void OnProxySettingsSubmit(bool applyProxy) - { - if (applyProxy) + if (response.IsSuccessful) + return (true, null); + + // Try to extract a meaningful error message from the response body + try { - Model.ProxySettings = ProxyCollection.Where(pr => pr.IsValid()) - .Select(pr => pr.MapTo()) - .ToList(); + using var doc = JsonDocument.Parse(response.Body); + if (doc.RootElement.TryGetProperty("error", out var errorElement)) + { + var message = errorElement.TryGetProperty("message", out var msgElement) + ? msgElement.GetString() + : response.Body; + return (false, message); + } } + catch { /* Ignore parse failures, fall through to generic message */ } - ProxySettingsIsOpened = false; + return (false, response.Body ?? "Unable to validate model. Check your API key, model name, and network."); } private async Task ChangeSourceLanguage(Languages language) @@ -281,43 +504,17 @@ private async Task ChangeTargetLanguage(Languages language) { this.TtsSettings.TtsLanguage = language; this.Model.TranslateToLang = language; - - if (TtsSettings.TtsSystem == TTSEngines.WindowsTTS) - { - var langCode = _languageService.GetLanguageDescriptor(language).Code; - LoadAvailableVoices(langCode); - } }; await this.ReconfigureTts(language, TtsSettings.TtsSystem, changeLanguageAction); OnPropertyChanged(nameof(TranslateToLang)); - OnPropertyChanged(nameof(IsTtsWindowsSelected)); - OnPropertyChanged(nameof(IsTtsEnabled)); } private async Task ChangeTtsSystem(TTSEngines engine) { - Action changeTtsEngineAction = () => - { - this.TtsSettings.TtsSystem = engine; - - if (engine == TTSEngines.WindowsTTS) - { - var langCode = _languageService.GetLanguageDescriptor(TtsSettings.TtsLanguage).Code; - LoadAvailableVoices(langCode); - } - else - { - AvailableVoices = new ObservableCollection(); - _selectedVoice = null; - OnPropertyChanged(nameof(SelectedVoice)); - } - }; - + Action changeTtsEngineAction = () => this.TtsSettings.TtsSystem = engine; await this.ReconfigureTts(TtsSettings.TtsLanguage, engine, changeTtsEngineAction); OnPropertyChanged(nameof(TtsSystem)); - OnPropertyChanged(nameof(IsTtsWindowsSelected)); - OnPropertyChanged(nameof(IsTtsEnabled)); } private async Task ReconfigureTts(Languages language, TTSEngines engine, Action changeParameter) @@ -381,4 +578,4 @@ public void Dispose() LocalizationManager.ReleaseChangedValuesCallbacks(this); } } -} \ No newline at end of file +} diff --git a/src/Translumo/MVVM/Views/LanguagesSettingsView.xaml b/src/Translumo/MVVM/Views/LanguagesSettingsView.xaml index 7a4054fe..1491e3b8 100644 --- a/src/Translumo/MVVM/Views/LanguagesSettingsView.xaml +++ b/src/Translumo/MVVM/Views/LanguagesSettingsView.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + +