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)