From 8c559f15bb918c42347a72058096655090f1dcfd Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Fri, 27 Mar 2026 21:36:24 -0700 Subject: [PATCH 1/9] add ollama module --- data/app.drey.Dialect.gschema.xml.in | 3 + dialect/preferences.py | 2 +- dialect/providers/base.py | 32 ++++++ dialect/providers/modules/bing.py | 1 + dialect/providers/modules/deepl.py | 1 + dialect/providers/modules/google.py | 1 + dialect/providers/modules/kagi.py | 1 + dialect/providers/modules/libretrans.py | 1 + dialect/providers/modules/lingva.py | 1 + dialect/providers/modules/ollama.py | 136 +++++++++++++++++++++++ dialect/providers/modules/yandex.py | 1 + dialect/providers/settings.py | 10 ++ dialect/widgets/provider_preferences.blp | 33 +++++- dialect/widgets/provider_preferences.py | 64 +++++++++++ dialect/window.py | 3 + 15 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 dialect/providers/modules/ollama.py 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..3b8e421a 100644 --- a/dialect/preferences.py +++ b/dialect/preferences.py @@ -105,7 +105,7 @@ 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..ed316a06 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() @@ -106,6 +108,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 +152,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. @@ -295,6 +310,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 +359,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..5dbf60fc --- /dev/null +++ b/dialect/providers/modules/ollama.py @@ -0,0 +1,136 @@ +# 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, + Translation, + TranslationRequest, +) +from dialect.providers.errors import RequestError, UnexpectedError +from dialect.providers.soup import SoupProvider + +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 = [ + "aa", "ab", "af", "ak", "am", "an", "ar", "as", "az", "ba", "be", "bg", "bm", "bn", "bo", + "br", "bs", "ca", "ce", "co", "cs", "cv", "cy", "da", "de", "dv", "dz", "ee", "el", "en", + "es", "et", "eu", "fa", "ff", "fi", "fo", "fr", "fy", "ga", "gd", "gl", "gn", "gu", "gv", + "ha", "he", "hi", "hr", "ht", "hu", "hy", "ia", "id", "ie", "ig", "ii", "ik", "io", "is", + "it", "iu", "ja", "jv", "ka", "ki", "kk", "kl", "km", "kn", "ko", "ks", "ku", "kw", "ky", + "la", "lb", "lg", "ln", "lo", "lt", "lu", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", + "mt", "my", "nb", "nd", "ne", "nl", "nn", "no", "nr", "nv", "ny", "oc", "om", "or", "os", + "pa", "pl", "ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sc", "sd", "se", "sg", + "si", "sk", "sl", "sn", "so", "sq", "sr", "ss", "st", "su", "sv", "sw", "ta", "te", "tg", + "th", "ti", "tk", "tl", "tn", "to", "tr", "ts", "tt", "ug", "uk", "ur", "uz", "ve", "vi", + "vo", "wa", "wo", "xh", "yi", "yo", "za", "zh", "zu", +] + +class Provider(SoupProvider): + name = "ollama" + prettyname = "Ollama" + + capabilities = ProviderCapability.TRANSLATION + features = ProviderFeature.INSTANCES | ProviderFeature.ENGINES | ProviderFeature.DETECTION | ProviderFeature.API_KEY + + defaults = { + "instance_url": "localhost:11434/api", + "engine_name": "translategemma", + "api_key": "", + "src_langs": [], + "dest_langs": ["en", "zh", "hi", "es", "ar"], + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @property + def generate_url(self): + return self.format_url(self.instance_url, "/generate") + + async def _fetch_model_names(self, url) -> list[str]: + response = await self.get(self.format_url(url, "/tags"), 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 translate(self, request: TranslationRequest) -> Translation: + prompt = self._build_prompt(request) + + data = { + "model": self.engine, + "prompt": prompt, + } + + # Ollama returns newline-delimited JSON (streaming) + raw = await self.post(self.generate_url, data, return_json=False) + + try: + parts = [] + for line in raw.splitlines(): + if not line: + continue + chunk = json.loads(line) + parts.append(chunk.get("response", "")) + translated = "".join(parts) + return Translation(translated, request) + except Exception as exc: + raise UnexpectedError 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..9437fa26 100644 --- a/dialect/window.py +++ b/dialect/window.py @@ -320,6 +320,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 ) From 4718731b50f38999796ea37cc1347de01873d7ff Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 12:31:52 -0700 Subject: [PATCH 2/9] add streaming output --- dialect/providers/base.py | 20 ++++++++++ dialect/providers/modules/ollama.py | 60 ++++++++++++++--------------- dialect/window.py | 14 ++++++- 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/dialect/providers/base.py b/dialect/providers/base.py index ed316a06..09802489 100644 --- a/dialect/providers/base.py +++ b/dialect/providers/base.py @@ -42,6 +42,9 @@ 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 """ + """ If it supports sending translation suggestions to the service """ class ProviderLangModel(Enum): @@ -199,6 +202,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. diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index 5dbf60fc..9635f0e6 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -12,6 +12,7 @@ ) from dialect.providers.errors import RequestError, UnexpectedError 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" @@ -31,17 +32,7 @@ ) SUPPORTED_LANGS = [ - "aa", "ab", "af", "ak", "am", "an", "ar", "as", "az", "ba", "be", "bg", "bm", "bn", "bo", - "br", "bs", "ca", "ce", "co", "cs", "cv", "cy", "da", "de", "dv", "dz", "ee", "el", "en", - "es", "et", "eu", "fa", "ff", "fi", "fo", "fr", "fy", "ga", "gd", "gl", "gn", "gu", "gv", - "ha", "he", "hi", "hr", "ht", "hu", "hy", "ia", "id", "ie", "ig", "ii", "ik", "io", "is", - "it", "iu", "ja", "jv", "ka", "ki", "kk", "kl", "km", "kn", "ko", "ks", "ku", "kw", "ky", - "la", "lb", "lg", "ln", "lo", "lt", "lu", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", - "mt", "my", "nb", "nd", "ne", "nl", "nn", "no", "nr", "nv", "ny", "oc", "om", "or", "os", - "pa", "pl", "ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sc", "sd", "se", "sg", - "si", "sk", "sl", "sn", "so", "sq", "sr", "ss", "st", "su", "sv", "sw", "ta", "te", "tg", - "th", "ti", "tk", "tl", "tn", "to", "tr", "ts", "tt", "ug", "uk", "ur", "uz", "ve", "vi", - "vo", "wa", "wo", "xh", "yi", "yo", "za", "zh", "zu", + "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): @@ -49,7 +40,7 @@ class Provider(SoupProvider): prettyname = "Ollama" capabilities = ProviderCapability.TRANSLATION - features = ProviderFeature.INSTANCES | ProviderFeature.ENGINES | ProviderFeature.DETECTION | ProviderFeature.API_KEY + features = ProviderFeature.INSTANCES | ProviderFeature.ENGINES | ProviderFeature.DETECTION | ProviderFeature.API_KEY | ProviderFeature.STREAMING defaults = { "instance_url": "localhost:11434/api", @@ -109,28 +100,37 @@ def _build_prompt(self, request: TranslationRequest) -> str: ) return prompt + request.text - async def translate(self, request: TranslationRequest) -> Translation: + async def stream_translate(self, request: TranslationRequest): prompt = self._build_prompt(request) - - data = { - "model": self.engine, - "prompt": prompt, - } - - # Ollama returns newline-delimited JSON (streaming) - raw = await self.post(self.generate_url, data, return_json=False) + data = {"model": self.engine, "prompt": prompt, "stream": True} + message = self.create_message("POST", self.generate_url, data) try: - parts = [] - for line in raw.splitlines(): - if not line: - continue - chunk = json.loads(line) - parts.append(chunk.get("response", "")) - translated = "".join(parts) - return Translation(translated, request) + stream = await Session.get().send_async(message, 0, None) + buf = b"" + while True: + chunk = await stream.read_bytes_async(4096, 0, None) + if chunk.get_size() == 0: + break + buf += chunk.get_data() + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + if not line: + continue + obj = json.loads(line) + token = obj.get("response", "") + if token: + yield token + if obj.get("done", False): + return except Exception as exc: - raise UnexpectedError from exc + raise RequestError(str(exc)) from exc + + async def translate(self, request: TranslationRequest) -> Translation: + parts = [] + async for token in self.stream_translate(request): + parts.append(token) + return Translation("".join(parts), request) def check_known_errors(self, status, data): pass diff --git a/dialect/window.py b/dialect/window.py index 9437fa26..4cfc401c 100644 --- a/dialect/window.py +++ b/dialect/window.py @@ -20,6 +20,7 @@ APIKeyRequired, BaseProvider, ProviderError, + ProviderFeature, RequestError, Translation, TranslationRequest, @@ -1166,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: @@ -1177,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 not ProviderFeature.STREAMING in self.provider["trans"].features: + self.dest_buffer.props.text = translation.text # Finally, translation is saved in history self.add_history_entry(translation) From 1b45d1c3faf7084c28c298fba3a48695e8b9ff52 Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 12:35:12 -0700 Subject: [PATCH 3/9] delete extra comment --- dialect/providers/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dialect/providers/base.py b/dialect/providers/base.py index 09802489..dcb94a65 100644 --- a/dialect/providers/base.py +++ b/dialect/providers/base.py @@ -44,7 +44,6 @@ class ProviderFeature(Flag): """ If it supports sending translation suggestions to the service """ STREAMING = auto() """ If it supports streaming translation tokens progressively """ - """ If it supports sending translation suggestions to the service """ class ProviderLangModel(Enum): From 7e8f8135c821efc6b9f6d565d27995cdec3e8e1c Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 12:44:09 -0700 Subject: [PATCH 4/9] fix syntax --- dialect/providers/modules/ollama.py | 2 +- dialect/window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index 9635f0e6..cd07059d 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -10,7 +10,7 @@ Translation, TranslationRequest, ) -from dialect.providers.errors import RequestError, UnexpectedError +from dialect.providers.errors import RequestError from dialect.providers.soup import SoupProvider from dialect.session import Session diff --git a/dialect/window.py b/dialect/window.py index 4cfc401c..b9b3be36 100644 --- a/dialect/window.py +++ b/dialect/window.py @@ -1186,7 +1186,7 @@ async def _on_translation(self, *_args): else: self.src_lang_selector.selected = translation.detected - if not ProviderFeature.STREAMING in self.provider["trans"].features: + if ProviderFeature.STREAMING not in self.provider["trans"].features: self.dest_buffer.props.text = translation.text # Finally, translation is saved in history From 06ca51930ce3accae819e7d0c952bf122f3ac063 Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 12:44:31 -0700 Subject: [PATCH 5/9] fix formatting --- dialect/preferences.py | 6 +++- dialect/providers/modules/ollama.py | 47 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/dialect/preferences.py b/dialect/preferences.py index 3b8e421a..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 or ProviderFeature.ENGINES 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/modules/ollama.py b/dialect/providers/modules/ollama.py index cd07059d..b3eb672e 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -32,15 +32,58 @@ ) 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" + "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 + features = ( + ProviderFeature.INSTANCES + | ProviderFeature.ENGINES + | ProviderFeature.DETECTION + | ProviderFeature.API_KEY + | ProviderFeature.STREAMING + ) defaults = { "instance_url": "localhost:11434/api", From 81a3e9cae45c49fad8f71da8b175f26c881289db Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 14:21:23 -0700 Subject: [PATCH 6/9] only english by default target language --- dialect/providers/modules/ollama.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index b3eb672e..70c66d20 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -90,7 +90,7 @@ class Provider(SoupProvider): "engine_name": "translategemma", "api_key": "", "src_langs": [], - "dest_langs": ["en", "zh", "hi", "es", "ar"], + "dest_langs": ["en"], } def __init__(self, **kwargs): From a2c209b4ab88086d807abe7da3dcdd3a84c69511 Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 15:31:46 -0700 Subject: [PATCH 7/9] add api feature --- dialect/providers/modules/ollama.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index 70c66d20..237be33d 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -100,8 +100,14 @@ def __init__(self, **kwargs): 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"), check_common=False) + 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): @@ -146,7 +152,7 @@ def _build_prompt(self, request: TranslationRequest) -> str: async def stream_translate(self, request: TranslationRequest): prompt = self._build_prompt(request) data = {"model": self.engine, "prompt": prompt, "stream": True} - message = self.create_message("POST", self.generate_url, data) + message = self.create_message("POST", self.generate_url, data, self.headers) try: stream = await Session.get().send_async(message, 0, None) From b4076d758cbfb9175525add9f8ba11ebc68012c9 Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 15:47:46 -0700 Subject: [PATCH 8/9] use gio for handling streaming response do not reinvent the wheel --- dialect/providers/modules/ollama.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index 237be33d..79fe878f 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -150,28 +150,23 @@ def _build_prompt(self, request: TranslationRequest) -> str: 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 = await Session.get().send_async(message, 0, None) - buf = b"" + stream = Gio.DataInputStream.new(await Session.get().send_async(message, 0, None)) while True: - chunk = await stream.read_bytes_async(4096, 0, None) - if chunk.get_size() == 0: + line, _ = await stream.read_line_async(0, None) + if not line: break - buf += chunk.get_data() - while b"\n" in buf: - line, buf = buf.split(b"\n", 1) - if not line: - continue - obj = json.loads(line) - token = obj.get("response", "") - if token: - yield token - if obj.get("done", False): - return + 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 From 950a2310e3663d8c4f269d4b7b79510d2ec3d492 Mon Sep 17 00:00:00 2001 From: Sunur Efe Vural Date: Sat, 28 Mar 2026 16:03:00 -0700 Subject: [PATCH 9/9] remove unused translate method If provider has streaming feature, `window.py::_on_translation` now chooses `stream_translate` over `translate`. Hence it is redundant to have a separate `translate` function in the provider. --- dialect/providers/modules/ollama.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dialect/providers/modules/ollama.py b/dialect/providers/modules/ollama.py index 79fe878f..414df54c 100644 --- a/dialect/providers/modules/ollama.py +++ b/dialect/providers/modules/ollama.py @@ -7,7 +7,6 @@ from dialect.providers.base import ( ProviderCapability, ProviderFeature, - Translation, TranslationRequest, ) from dialect.providers.errors import RequestError @@ -170,11 +169,5 @@ async def stream_translate(self, request: TranslationRequest): except Exception as exc: raise RequestError(str(exc)) from exc - async def translate(self, request: TranslationRequest) -> Translation: - parts = [] - async for token in self.stream_translate(request): - parts.append(token) - return Translation("".join(parts), request) - def check_known_errors(self, status, data): pass