diff --git a/data/app.drey.Dialect.gschema.xml.in b/data/app.drey.Dialect.gschema.xml.in index 36977624..239835e5 100644 --- a/data/app.drey.Dialect.gschema.xml.in +++ b/data/app.drey.Dialect.gschema.xml.in @@ -69,6 +69,9 @@ "" + + "" + "" diff --git a/dialect/preferences.py b/dialect/preferences.py index 244dd991..50c603a7 100644 --- a/dialect/preferences.py +++ b/dialect/preferences.py @@ -105,7 +105,11 @@ def _provider_has_settings(self, name: str): if not name: return False - if ProviderFeature.INSTANCES in MODULES[name].features or ProviderFeature.API_KEY in MODULES[name].features: + if ( + ProviderFeature.INSTANCES in MODULES[name].features + or ProviderFeature.API_KEY in MODULES[name].features + or ProviderFeature.ENGINES in MODULES[name].features + ): return True return False diff --git a/dialect/providers/base.py b/dialect/providers/base.py index 91fe9fed..dcb94a65 100644 --- a/dialect/providers/base.py +++ b/dialect/providers/base.py @@ -26,6 +26,8 @@ class ProviderFeature(Flag): """ Provider has no features """ INSTANCES = auto() """ If it supports changing the instance url """ + ENGINES = auto() + """ If it supports changing the translation engine model """ API_KEY = auto() """ If the api key is supported but not necessary """ API_KEY_REQUIRED = auto() @@ -40,6 +42,8 @@ class ProviderFeature(Flag): """ If it supports showing translation pronunciation """ SUGGESTIONS = auto() """ If it supports sending translation suggestions to the service """ + STREAMING = auto() + """ If it supports streaming translation tokens progressively """ class ProviderLangModel(Enum): @@ -106,6 +110,7 @@ class BaseProvider: defaults: ProviderDefaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], @@ -149,6 +154,18 @@ async def validate_instance(self, url: str) -> bool: """ raise NotImplementedError() + async def validate_engine(self, name: str) -> bool: + """ + Validate a translation engine model name. + + Args: + name: The engine/model name to validate. + + Returns: + If the engine name is valid and available. + """ + raise NotImplementedError() + async def validate_api_key(self, key: str) -> bool: """ Validate an API key. @@ -184,6 +201,23 @@ async def translate(self, request: TranslationRequest) -> Translation: """ raise NotImplementedError() + async def stream_translate(self, request: TranslationRequest): + """ + Streams translation tokens progressively (async generator). + + Only available when ``ProviderFeature.STREAMING`` is in features. + + Args: + request: The translation request. + + Yields: + str tokens as they arrive. + """ + raise NotImplementedError() + # Make this an async generator + return + yield # noqa + async def suggest(self, text: str, src: str, dest: str, suggestion: str) -> bool: """ Sends a translation suggestion to the provider. @@ -295,6 +329,10 @@ def lang_aliases(self) -> dict[str, str]: def supports_instances(self) -> bool: return ProviderFeature.INSTANCES in self.features + @property + def supports_engines(self) -> bool: + return ProviderFeature.ENGINES in self.features + @property def supports_api_key(self) -> bool: return ProviderFeature.API_KEY in self.features @@ -340,6 +378,19 @@ def reset_instance_url(self): """Resets saved instance url""" self.instance_url = "" + @property + def engine(self) -> str: + """Translation engine model name saved on settings""" + return self.settings.engine + + @engine.setter + def engine(self, name: str): + self.settings.engine = name + + def reset_engine(self): + """Resets saved translation engine model name""" + self.engine = "" + @property def api_key(self) -> str: """API key saved on settings""" diff --git a/dialect/providers/modules/bing.py b/dialect/providers/modules/bing.py index a0a56d63..703140f8 100644 --- a/dialect/providers/modules/bing.py +++ b/dialect/providers/modules/bing.py @@ -19,6 +19,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], diff --git a/dialect/providers/modules/deepl.py b/dialect/providers/modules/deepl.py index 56b529d7..8f471204 100644 --- a/dialect/providers/modules/deepl.py +++ b/dialect/providers/modules/deepl.py @@ -24,6 +24,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en-US"], diff --git a/dialect/providers/modules/google.py b/dialect/providers/modules/google.py index 28e1c854..9b03d913 100644 --- a/dialect/providers/modules/google.py +++ b/dialect/providers/modules/google.py @@ -234,6 +234,7 @@ class Provider(LocalProvider, SoupProvider): defaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], diff --git a/dialect/providers/modules/kagi.py b/dialect/providers/modules/kagi.py index a6c10921..617b99a4 100644 --- a/dialect/providers/modules/kagi.py +++ b/dialect/providers/modules/kagi.py @@ -25,6 +25,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de", "ja", "zh"], "dest_langs": ["fr", "es", "de", "en", "ja", "zh"], diff --git a/dialect/providers/modules/libretrans.py b/dialect/providers/modules/libretrans.py index 79ddb88a..658e8809 100644 --- a/dialect/providers/modules/libretrans.py +++ b/dialect/providers/modules/libretrans.py @@ -27,6 +27,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "lt.dialectapp.org", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], diff --git a/dialect/providers/modules/lingva.py b/dialect/providers/modules/lingva.py index 1cb4261e..e7b261bd 100644 --- a/dialect/providers/modules/lingva.py +++ b/dialect/providers/modules/lingva.py @@ -27,6 +27,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "lingva.dialectapp.org", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py new file mode 100644 index 00000000..414df54c --- /dev/null +++ b/dialect/providers/modules/ollama.py @@ -0,0 +1,173 @@ +# Copyright 2026 Sunur Efe Vural +# SPDX-License-Identifier: GPL-3.0-or-later + +import json + +from dialect.define import LANGUAGES as ALL_LANGUAGES +from dialect.providers.base import ( + ProviderCapability, + ProviderFeature, + TranslationRequest, +) +from dialect.providers.errors import RequestError +from dialect.providers.soup import SoupProvider +from dialect.session import Session + +AUTO_PROMPT = ( + "You are a professional {dest_lang} ({dest_code}) translator. Your goal is to" + " accurately convey the meaning and nuances of the original text while adhering" + " to {dest_lang} grammar, vocabulary, and cultural sensitivities.\n" + "Produce only the {dest_lang} translation, without any additional explanations or" + " commentary. Please translate the following text into {dest_lang}:\n\n\n" +) + +TRANSLATE_PROMPT = ( + "You are a professional {src_lang} ({src_code}) to {dest_lang} ({dest_code})" + " translator. Your goal is to accurately convey the meaning and nuances of the" + " original {src_lang} text while adhering to {dest_lang} grammar, vocabulary," + " and cultural sensitivities.\n" + "Produce only the {dest_lang} translation, without any additional explanations or" + " commentary. Please translate the following {src_lang} text into {dest_lang}:\n\n\n" +) + +SUPPORTED_LANGS = [ + "zh", + "en", + "fr", + "pt", + "es", + "ja", + "tr", + "ru", + "ar", + "ko", + "th", + "it", + "de", + "vi", + "ms", + "id", + "tl", + "hi", + "pl", + "cs", + "nl", + "km", + "my", + "fa", + "gu", + "ur", + "te", + "mr", + "he", + "bn", + "ta", + "uk", + "bo", + "kk", + "mn", + "ug", + "yue", +] + + +class Provider(SoupProvider): + name = "ollama" + prettyname = "Ollama" + + capabilities = ProviderCapability.TRANSLATION + features = ( + ProviderFeature.INSTANCES + | ProviderFeature.ENGINES + | ProviderFeature.DETECTION + | ProviderFeature.API_KEY + | ProviderFeature.STREAMING + ) + + defaults = { + "instance_url": "localhost:11434/api", + "engine_name": "translategemma", + "api_key": "", + "src_langs": [], + "dest_langs": ["en"], + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @property + def generate_url(self): + return self.format_url(self.instance_url, "/generate") + + @property + def headers(self) -> dict: + if self.api_key: + return {"Authorization": f"Bearer {self.api_key}"} + return {} + + async def _fetch_model_names(self, url) -> list[str]: + response = await self.get(self.format_url(url, "/tags"), self.headers, check_common=False) + return [m["name"] for m in response.get("models", [])] + + async def validate_instance(self, url): + try: + return bool(await self._fetch_model_names(url)) + except Exception: + return False + + async def validate_engine(self, name): + try: + available = await self._fetch_model_names(self.instance_url) + engine = name if ":" in name else name + ":latest" + return engine in available + except Exception: + return False + + async def init_trans(self): + available = await self._fetch_model_names(self.instance_url) + if not available: + raise RequestError("Ollama instance not reachable or has no models") + engine = self.engine if ":" in self.engine else self.engine + ":latest" + if engine not in available: + raise RequestError(f'Model "{self.engine}" is not available on this Ollama instance') + for code in SUPPORTED_LANGS: + self.add_lang(code, ALL_LANGUAGES.get(code)) + + def _build_prompt(self, request: TranslationRequest) -> str: + dest_lang = self._languages_names.get(request.dest, request.dest) + + if request.src == "auto": + return AUTO_PROMPT.format(dest_lang=dest_lang, dest_code=request.dest) + request.text + else: + src_lang = self._languages_names.get(request.src, request.src) + prompt = TRANSLATE_PROMPT.format( + src_lang=src_lang, + src_code=request.src, + dest_lang=dest_lang, + dest_code=request.dest, + ) + return prompt + request.text + + async def stream_translate(self, request: TranslationRequest): + from gi.repository import Gio + + prompt = self._build_prompt(request) + data = {"model": self.engine, "prompt": prompt, "stream": True} + message = self.create_message("POST", self.generate_url, data, self.headers) + + try: + stream = Gio.DataInputStream.new(await Session.get().send_async(message, 0, None)) + while True: + line, _ = await stream.read_line_async(0, None) + if not line: + break + obj = json.loads(line) + if token := obj.get("response"): + yield token + if obj.get("done", False): + return + except Exception as exc: + raise RequestError(str(exc)) from exc + + def check_known_errors(self, status, data): + pass diff --git a/dialect/providers/modules/yandex.py b/dialect/providers/modules/yandex.py index a8bdd764..9aed67bc 100644 --- a/dialect/providers/modules/yandex.py +++ b/dialect/providers/modules/yandex.py @@ -25,6 +25,7 @@ class Provider(SoupProvider): defaults = { "instance_url": "", + "engine_name": "", "api_key": "", "src_langs": ["en", "fr", "es", "de"], "dest_langs": ["fr", "es", "de", "en"], diff --git a/dialect/providers/settings.py b/dialect/providers/settings.py index 2ff3475e..69bfb404 100644 --- a/dialect/providers/settings.py +++ b/dialect/providers/settings.py @@ -20,6 +20,7 @@ class ProviderDefaults(TypedDict): instance_url: str + engine_name: str api_key: str src_langs: list[str] dest_langs: list[str] @@ -47,6 +48,15 @@ def instance_url(self) -> str: def instance_url(self, url: str): self.set_string("instance-url", url) + @property + def engine(self) -> str: + """Translation engine model name.""" + return self.get_string("engine-name") or self.defaults["engine_name"] + + @engine.setter + def engine(self, name: str): + self.set_string("engine-name", name) + @property def api_key(self) -> str: """API key.""" diff --git a/dialect/widgets/provider_preferences.blp b/dialect/widgets/provider_preferences.blp index 59b12da2..667f0949 100644 --- a/dialect/widgets/provider_preferences.blp +++ b/dialect/widgets/provider_preferences.blp @@ -48,6 +48,37 @@ template $ProviderPreferences : Adw.NavigationPage { } } + Adw.EntryRow engine_entry { + title: _("Model name"); + tooltip-text: _("Enter the name of the translation engine model."); + show-apply-button: true; + + apply => $_on_engine_apply(); + notify::text => $_on_engine_changed(); + + Stack engine_stack { + StackPage { + name: "reset"; + child: Button engine_reset { + tooltip-text: _("Reset to Default"); + icon-name: "view-refresh-symbolic"; + valign: center; + + clicked => $_on_reset_engine(); + + styles ["flat"] + }; + } + + StackPage { + name: "spinner"; + child: Adw.Spinner { + valign: center; + }; + } + } + } + Adw.PasswordEntryRow api_key_entry { title: _("API Key"); tooltip-text: _("Enter an API Key for the Provider."); @@ -99,4 +130,4 @@ template $ProviderPreferences : Adw.NavigationPage { } } } -} \ No newline at end of file +} diff --git a/dialect/widgets/provider_preferences.py b/dialect/widgets/provider_preferences.py index 9cb6bca4..6457117f 100644 --- a/dialect/widgets/provider_preferences.py +++ b/dialect/widgets/provider_preferences.py @@ -33,6 +33,9 @@ class ProviderPreferences(Adw.NavigationPage): instance_entry: Adw.EntryRow = Gtk.Template.Child() # type: ignore instance_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore instance_reset: Gtk.Button = Gtk.Template.Child() # type: ignore + engine_entry: Adw.EntryRow = Gtk.Template.Child() # type: ignore + engine_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore + engine_reset: Gtk.Button = Gtk.Template.Child() # type: ignore api_key_entry: Adw.PasswordEntryRow = Gtk.Template.Child() # type: ignore api_key_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore api_key_reset: Gtk.Button = Gtk.Template.Child() # type: ignore @@ -60,6 +63,7 @@ def __init__(self, scope: str, dialog: Adw.PreferencesDialog, window: DialectWin # Load saved values self.instance_entry.props.text = self.provider.instance_url + self.engine_entry.props.text = self.provider.engine self.api_key_entry.props.text = self.provider.api_key # Main window progress @@ -70,6 +74,7 @@ def _check_settings(self): return self.instance_entry.props.visible = self.provider.supports_instances + self.engine_entry.props.visible = self.provider.supports_engines self.api_key_entry.props.visible = self.provider.supports_api_key self.api_usage_group.props.visible = False @@ -158,6 +163,63 @@ def _on_reset_instance(self, _button): self.instance_entry.remove_css_class("error") self.instance_entry.props.text = self.provider.instance_url + @Gtk.Template.Callback() + @background_task + async def _on_engine_apply(self, _row): + """Called on self.engine_entry::apply signal""" + if not self.provider: + return + + old_value = self.provider.engine + new_value = self.engine_entry.props.text.strip() + + if new_value != old_value: + self.engine_entry.props.sensitive = False + self.engine_stack.props.visible_child_name = "spinner" + + try: + if await self.provider.validate_engine(new_value): + self.provider.engine = new_value + self.engine_entry.remove_css_class("error") + self.engine_entry.props.text = self.provider.engine + else: + self.engine_entry.add_css_class("error") + error_text = _('Model "{name}" is not available on this instance') + error_text = error_text.format(name=new_value) + toast = Adw.Toast(title=error_text) + self.dialog.add_toast(toast) + except RequestError as exc: + logging.error(exc) + toast = Adw.Toast(title=_("Failed validating engine, check for network issues")) + self.dialog.add_toast(toast) + finally: + self.engine_entry.props.sensitive = True + self.engine_stack.props.visible_child_name = "reset" + else: + self.engine_entry.remove_css_class("error") + + @Gtk.Template.Callback() + def _on_engine_changed(self, _entry, _pspec): + """Called on self.engine_entry::notify::text signal""" + if not self.provider: + return + + if self.engine_entry.props.text == self.provider.engine: + self.engine_entry.props.show_apply_button = False + elif not self.engine_entry.props.show_apply_button: + self.engine_entry.props.show_apply_button = True + + @Gtk.Template.Callback() + def _on_reset_engine(self, _button): + if not self.provider: + return + + if self.provider.engine != self.provider.defaults["engine_name"]: + self.provider.reset_engine() + + self.engine_entry.remove_css_class("error") + self.engine_entry.props.text = self.provider.engine + @Gtk.Template.Callback() @background_task async def _on_api_key_apply(self, _row): @@ -172,6 +234,7 @@ async def _on_api_key_apply(self, _row): if self.new_api_key != old_value: # Progress feedback self.instance_entry.props.sensitive = False + self.engine_entry.props.sensitive = False self.api_key_entry.props.sensitive = False self.api_key_stack.props.visible_child_name = "spinner" @@ -192,6 +255,7 @@ async def _on_api_key_apply(self, _row): self.dialog.add_toast(toast) finally: self.instance_entry.props.sensitive = True + self.engine_entry.props.sensitive = True self.api_key_entry.props.sensitive = True self.api_key_stack.props.visible_child_name = "reset" else: diff --git a/dialect/window.py b/dialect/window.py index 834953ae..b9b3be36 100644 --- a/dialect/window.py +++ b/dialect/window.py @@ -20,6 +20,7 @@ APIKeyRequired, BaseProvider, ProviderError, + ProviderFeature, RequestError, Translation, TranslationRequest, @@ -320,6 +321,9 @@ async def load_translator(self): self.provider["trans"].settings.connect( "changed::instance-url", self._on_provider_changed, self.provider["trans"].name ) + self.provider["trans"].settings.connect( + "changed::engine-name", self._on_provider_changed, self.provider["trans"].name + ) self.provider["trans"].settings.connect( "changed::api-key", self._on_provider_changed, self.provider["trans"].name ) @@ -1163,7 +1167,15 @@ async def _on_translation(self, *_args): self.translation_loading = True try: - translation = await self.provider["trans"].translate(request) + if ProviderFeature.STREAMING in self.provider["trans"].features: + self.dest_buffer.props.text = "" + parts = [] + async for token in self.provider["trans"].stream_translate(request): + parts.append(token) + self.dest_buffer.props.text = "".join(parts) + translation = Translation("".join(parts), request) + else: + translation = await self.provider["trans"].translate(request) if translation.detected and self.src_lang_selector.selected == "auto": if Settings.get().src_auto: @@ -1174,7 +1186,8 @@ async def _on_translation(self, *_args): else: self.src_lang_selector.selected = translation.detected - self.dest_buffer.props.text = translation.text + if ProviderFeature.STREAMING not in self.provider["trans"].features: + self.dest_buffer.props.text = translation.text # Finally, translation is saved in history self.add_history_entry(translation)