From dfe4658322e8c0f212dd2703d453903471d24ba4 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 19:23:53 +0100 Subject: [PATCH 01/13] chore(ders): add mako dependency --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c3d88faae..d1b7b7842 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "httpx>=0.28.1,<0.29.0", "keyring>=25.7.0,<25.8", "markdown2>=2.5.4,<2.6", + "mako>=1.3.10", "mistralai>=1.10.1,<1.12.5", "more-itertools>=10.8.0,<10.8.1", "numpy>=2.4.0,<2.5", diff --git a/uv.lock b/uv.lock index 7719725c0..63a571098 100644 --- a/uv.lock +++ b/uv.lock @@ -104,6 +104,7 @@ dependencies = [ { name = "google-genai" }, { name = "httpx" }, { name = "keyring" }, + { name = "mako" }, { name = "markdown2" }, { name = "mistralai" }, { name = "more-itertools" }, @@ -164,6 +165,7 @@ requires-dist = [ { name = "google-genai", specifier = ">=1.47.0,<1.67" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "keyring", specifier = ">=25.7.0,<25.8" }, + { name = "mako", specifier = ">=1.3.10" }, { name = "markdown2", specifier = ">=2.5.4,<2.6" }, { name = "mistralai", specifier = ">=1.10.1,<1.12.5" }, { name = "more-itertools", specifier = ">=10.8.0,<10.8.1" }, From fa9e32c0698c1c3e1a7d70ea5627e258a8e1c485 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 19:29:56 +0100 Subject: [PATCH 02/13] chore: add .worktrees to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c6cf4399f..3cecd4d05 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ basilisk_config.yml *.mo user_data/ coverage.xml +.worktrees/ From 9144f07b51bc3999b3dec4b254b7f06b5eefbfb8 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 19:47:07 +0100 Subject: [PATCH 03/13] feat: add TemplateService for Mako-based prompt and HTML rendering --- basilisk/global_vars.py | 2 + .../res/templates/conversation_export.mako | 73 +++++++++ basilisk/res/templates/html_message.mako | 10 ++ basilisk/services/template_service.py | 154 ++++++++++++++++++ tests/services/test_template_service.py | 117 +++++++++++++ 5 files changed, 356 insertions(+) create mode 100644 basilisk/res/templates/conversation_export.mako create mode 100644 basilisk/res/templates/html_message.mako create mode 100644 basilisk/services/template_service.py create mode 100644 tests/services/test_template_service.py diff --git a/basilisk/global_vars.py b/basilisk/global_vars.py index 868553b4d..6edece71c 100644 --- a/basilisk/global_vars.py +++ b/basilisk/global_vars.py @@ -22,6 +22,8 @@ resource_path = base_path / Path("res") # sounds directory (contains sound, etc.) sounds_path = resource_path / "sounds" +# templates directory (contains default Mako templates) +templates_path = resource_path / "templates" # command-line arguments parsed by the application args = None diff --git a/basilisk/res/templates/conversation_export.mako b/basilisk/res/templates/conversation_export.mako new file mode 100644 index 000000000..4734b7df2 --- /dev/null +++ b/basilisk/res/templates/conversation_export.mako @@ -0,0 +1,73 @@ + + + + + ${conversation.title or _("Conversation") | h} + + + +
+

${conversation.title or _("Conversation") | h}

+ % if profile: +

${_("Profile")}: ${profile.name | h}

+ % endif +
+
+% for block in conversation.messages: +
+
${block.request.content | h}
+ % if block.request.attachments: + % for att in block.request.attachments: + <% + mime = att.mime_type or "application/octet-stream" + is_image = mime.startswith("image/") + b64 = att.encoded_data if hasattr(att, "encoded_data") else "" + %> + % if is_image: + ${att.name | h} + % else: + ${_("Download")} ${att.name | h} + % endif + % endfor + % endif +
+ % if block.response: +
+
${block.response.content | h}
+

+ ${block.model.name | h} — + +

+
+ % endif +% endfor +
+ + + diff --git a/basilisk/res/templates/html_message.mako b/basilisk/res/templates/html_message.mako new file mode 100644 index 000000000..fda6686a6 --- /dev/null +++ b/basilisk/res/templates/html_message.mako @@ -0,0 +1,10 @@ + + + + + ${title | h} + + + ${content} + + diff --git a/basilisk/services/template_service.py b/basilisk/services/template_service.py new file mode 100644 index 000000000..c6b0cfc35 --- /dev/null +++ b/basilisk/services/template_service.py @@ -0,0 +1,154 @@ +"""Mako-based template rendering service.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from basilisk import global_vars + +log = logging.getLogger(__name__) + + +def _render_file(template_path: Path, context: dict[str, Any]) -> str: + """Render a Mako template file with the given context. + + Args: + template_path: Path to the .mako template file. + context: Variables available in the template. + + Returns: + The rendered string. + + Raises: + ValueError: On any Mako syntax or runtime error. + """ + from mako.exceptions import MakoException + from mako.lookup import TemplateLookup + + lookup = TemplateLookup( + directories=[str(template_path.parent)], filesystem_checks=True + ) + try: + tpl = lookup.get_template(template_path.name) + return tpl.render(**context) + except MakoException as exc: + raise ValueError(f"Mako template error: {exc}") from exc + except Exception as exc: + raise ValueError(f"Template runtime error: {exc}") from exc + + +def _render_inline(template_str: str, context: dict[str, Any]) -> str: + """Render a Mako template string with the given context. + + Args: + template_str: The Mako template source. + context: Variables available in the template. + + Returns: + The rendered string. + + Raises: + ValueError: On any Mako syntax or runtime error. + """ + from mako.exceptions import MakoException + from mako.template import Template + + try: + tpl = Template(template_str) + return tpl.render(**context) + except MakoException as exc: + raise ValueError(f"Mako template error: {exc}") from exc + except Exception as exc: + raise ValueError(f"Template runtime error: {exc}") from exc + + +class TemplateService: + """Centralised Mako rendering service. + + All methods are static — no instance state. Presenters call these + methods; they never import Mako directly. + """ + + @staticmethod + def render_prompt(template_str: str, context: dict[str, Any]) -> str: + """Render a system-prompt Mako template. + + Args: + template_str: Raw template string from ConversationProfile. + context: Variables injected into the template namespace. + + Returns: + Rendered plain-text prompt. + + Raises: + ValueError: On Mako syntax or runtime error. + """ + return _render_inline(template_str, context) + + @staticmethod + def render_html_message( + content: str, title: str, template_path: Path | None + ) -> str: + """Render the single-message HTML wrapper. + + Uses the file at *template_path* when it exists, otherwise falls + back to the default template from the resource directory. + + Args: + content: HTML body content (already converted from Markdown). + title: Page/window title. + template_path: Optional path to a custom .mako file on disk. + + Returns: + Complete HTML document string. + """ + ctx = {"title": title, "content": content} + if template_path and template_path.exists(): + try: + return _render_file(template_path, ctx) + except Exception as exc: + log.warning( + "Custom HTML template failed, using default: %s", exc + ) + default = global_vars.templates_path / "html_message.mako" + return _render_file(default, ctx) + + @staticmethod + def render_conversation_export( + conversation: Any, + profile: Any | None, + template_path: Path | None, + extra_context: dict[str, Any] | None = None, + ) -> str: + """Render a full conversation as an HTML document. + + Args: + conversation: The Conversation model instance. + profile: The ConversationProfile or None. + template_path: Optional path to a custom .mako file. + extra_context: Additional variables merged into context + (used in tests to inject translation stubs). + + Returns: + Complete HTML document string. + """ + ctx: dict[str, Any] = { + "conversation": conversation, + "profile": profile, + "_": _, + "ngettext": ngettext, + "pgettext": pgettext, + } + if extra_context: + ctx.update(extra_context) + if template_path and template_path.exists(): + try: + return _render_file(template_path, ctx) + except Exception as exc: + log.warning( + "Custom export template failed, using default: %s", exc + ) + default = global_vars.templates_path / "conversation_export.mako" + return _render_file(default, ctx) diff --git a/tests/services/test_template_service.py b/tests/services/test_template_service.py new file mode 100644 index 000000000..68d50f687 --- /dev/null +++ b/tests/services/test_template_service.py @@ -0,0 +1,117 @@ +"""Tests for TemplateService.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from basilisk.services.template_service import TemplateService + + +class TestRenderPrompt: + """Tests for TemplateService.render_prompt.""" + + def test_plain_text_passthrough(self): + """Plain text without Mako syntax is returned unchanged.""" + result = TemplateService.render_prompt("Hello world", {}) + assert result == "Hello world" + + def test_variable_substitution(self): + """Mako ${var} syntax is replaced with context values.""" + result = TemplateService.render_prompt( + "Hello ${name}", {"name": "Alice"} + ) + assert result == "Hello Alice" + + def test_python_block_execution(self): + """Python blocks <% %> are executed.""" + result = TemplateService.render_prompt( + "<%\nx = 1 + 1\n%>\nResult: ${x}", {} + ) + assert result.strip() == "Result: 2" + + def test_import_in_block(self): + """Stdlib imports inside blocks work (no sandbox).""" + result = TemplateService.render_prompt( + "<%\nimport platform\n%>${platform.system() != ''}", {} + ) + assert "True" in result + + def test_syntax_error_raises_value_error(self): + """Invalid Mako syntax raises ValueError.""" + with pytest.raises(ValueError, match="template"): + TemplateService.render_prompt("${unclosed", {}) + + def test_runtime_error_raises_value_error(self): + """Runtime exception in template raises ValueError.""" + with pytest.raises(ValueError, match="runtime"): + TemplateService.render_prompt("${1/0}", {}) + + def test_context_injected(self): + """Context dict values are available as template variables.""" + from datetime import datetime + + now = datetime(2026, 3, 8, 12, 0, 0) + result = TemplateService.render_prompt( + "${now.strftime('%Y-%m-%d')}", {"now": now} + ) + assert result == "2026-03-08" + + +class TestRenderHtmlMessage: + """Tests for TemplateService.render_html_message.""" + + def test_default_template_contains_title(self): + """Default template wraps content with title.""" + result = TemplateService.render_html_message( + "

body

", "My Title", None + ) + assert "My Title" in result + assert "

body

" in result + assert "" in result + + def test_custom_template_from_disk(self, tmp_path): + """Custom template file is loaded and rendered.""" + tpl = tmp_path / "custom.mako" + tpl.write_text("

${title}

${content}", encoding="utf-8") + result = TemplateService.render_html_message("hello", "T", tpl) + assert result == "

T

hello" + + def test_missing_custom_template_falls_back_to_default(self, tmp_path): + """Non-existent path falls back to embedded default template.""" + result = TemplateService.render_html_message( + "body", "Title", tmp_path / "nonexistent.mako" + ) + assert "Title" in result + assert "body" in result + + +class TestRenderConversationExport: + """Tests for TemplateService.render_conversation_export.""" + + def test_translation_functions_available(self): + """_ ngettext pgettext are usable in template.""" + mock_conv = MagicMock() + mock_conv.title = "Test" + mock_conv.messages = [] + + def fake_translate(s): + return f"[{s}]" + + result = TemplateService.render_conversation_export( + mock_conv, None, None, extra_context={"_": fake_translate} + ) + # Just verify it renders without error + assert isinstance(result, str) + + def test_default_template_is_valid_html(self): + """Default export template produces valid HTML structure.""" + mock_conv = MagicMock() + mock_conv.title = "My Conv" + mock_conv.messages = [] + result = TemplateService.render_conversation_export( + mock_conv, None, None + ) + assert "" in result + assert " Date: Sun, 8 Mar 2026 20:00:01 +0100 Subject: [PATCH 04/13] feat: use TemplateService in HtmlViewWindow instead of str.format --- basilisk/config/__init__.py | 3 ++- basilisk/config/main_config.py | 9 +++++++++ basilisk/views/html_view_window.py | 18 +++++++----------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/basilisk/config/__init__.py b/basilisk/config/__init__.py index ed1de4338..19776dd6e 100644 --- a/basilisk/config/__init__.py +++ b/basilisk/config/__init__.py @@ -18,7 +18,7 @@ from .conversation_profile import ( get_conversation_profile_config as conversation_profiles, ) -from .main_config import BasiliskConfig +from .main_config import BasiliskConfig, TemplatesSettings from .main_config import get_basilisk_config as conf __all__ = [ @@ -37,4 +37,5 @@ "KeyStorageMethodEnum", "LogLevelEnum", "ReleaseChannelEnum", + "TemplatesSettings", ] diff --git a/basilisk/config/main_config.py b/basilisk/config/main_config.py index 4e2a778ce..6f65a77fe 100644 --- a/basilisk/config/main_config.py +++ b/basilisk/config/main_config.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from functools import cache +from pathlib import Path from pydantic import BaseModel, Field, model_validator @@ -81,6 +82,13 @@ class NetworkSettings(BaseModel): use_system_cert_store: bool = Field(default=True) +class TemplatesSettings(BaseModel): + """Template path settings.""" + + html_message_template_path: Path | None = Field(default=None) + html_export_template_path: Path | None = Field(default=None) + + class BasiliskConfig(BasiliskBaseSettings): """BasiliskLLM configuration settings.""" @@ -94,6 +102,7 @@ class BasiliskConfig(BasiliskBaseSettings): recordings: RecordingsSettings = Field(default_factory=RecordingsSettings) network: NetworkSettings = Field(default_factory=NetworkSettings) server: ServerSettings = Field(default_factory=ServerSettings) + templates: TemplatesSettings = Field(default_factory=TemplatesSettings) @model_validator(mode="before") @classmethod diff --git a/basilisk/views/html_view_window.py b/basilisk/views/html_view_window.py index e62ba35a1..3158c8f0a 100644 --- a/basilisk/views/html_view_window.py +++ b/basilisk/views/html_view_window.py @@ -11,17 +11,10 @@ import wx import wx.html2 +import basilisk.config as config +from basilisk.services.template_service import TemplateService + VALID_FORMATS = ["html", "markdown"] -HTML_TEMPLATE = """ - - - - {title} - - - {content} - -""" class HtmlViewWindow(wx.Frame): @@ -65,7 +58,10 @@ def __init__( ], ) - content = HTML_TEMPLATE.format(title=title, content=content) + template_path = config.conf().templates.html_message_template_path + content = TemplateService.render_html_message( + content, title, template_path + ) super().__init__(parent, title=title, size=(800, 600)) self._content = content From 7ae1c365b138cfa3bd533a8739905c9eb0a9260e Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:00:15 +0100 Subject: [PATCH 05/13] feat: add TemplatesSettings to BasiliskConfig for custom template paths --- basilisk/presenters/preferences_presenter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/basilisk/presenters/preferences_presenter.py b/basilisk/presenters/preferences_presenter.py index c22eecb42..4256f0c05 100644 --- a/basilisk/presenters/preferences_presenter.py +++ b/basilisk/presenters/preferences_presenter.py @@ -117,6 +117,12 @@ def on_ok(self) -> None: ) conf.server.enable = self.view.server_enable.GetValue() conf.server.port = int(self.view.server_port.GetValue()) + conf.templates.html_message_template_path = ( + self.view.html_message_template_path.get_path() + ) + conf.templates.html_export_template_path = ( + self.view.html_export_template_path.get_path() + ) conf.save() set_log_level(conf.general.log_level.name) From cae121f686019fd81da027ce52895ae4c54b57f9 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:04:43 +0100 Subject: [PATCH 06/13] feat: add HTML template path controls to Preferences dialog --- basilisk/views/preferences_dialog.py | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/basilisk/views/preferences_dialog.py b/basilisk/views/preferences_dialog.py index 7b9715506..ddec65d6b 100644 --- a/basilisk/views/preferences_dialog.py +++ b/basilisk/views/preferences_dialog.py @@ -1,10 +1,13 @@ """Preferences dialog for the BasiliskLLM application.""" import logging +import shutil +from pathlib import Path import wx import basilisk.config as config +from basilisk import global_vars from basilisk.presenters.preferences_presenter import ( AUTO_UPDATE_MODES, LOG_LEVELS, @@ -15,6 +18,95 @@ log = logging.getLogger(__name__) +class TemplatePathWidget: + """A label + text field + Browse + Write-default-template buttons group.""" + + def __init__( + self, + parent: wx.Window, + sizer: wx.Sizer, + label: str, + initial_path, + default_template_filename: str, + ): + """Create the widget group. + + Args: + parent: The parent window. + sizer: The sizer to add widgets to. + label: Label text displayed above the path field. + initial_path: Initial Path value (or None). + default_template_filename: Filename in the templates resource dir + to copy when the user clicks "Write default template". + """ + self._default_template_filename = default_template_filename + self._parent = parent + lbl = wx.StaticText(parent, label=label) + sizer.Add(lbl, 0, wx.ALL, 5) + + row = wx.BoxSizer(wx.HORIZONTAL) + self._path_ctrl = wx.TextCtrl( + parent, value=str(initial_path) if initial_path else "" + ) + row.Add(self._path_ctrl, 1, wx.EXPAND | wx.RIGHT, 4) + + browse_btn = wx.Button( + parent, + # Translators: Button to browse for a file path + label=_("Browse…"), + ) + browse_btn.Bind(wx.EVT_BUTTON, self._on_browse) + row.Add(browse_btn, 0) + + write_btn = wx.Button( + parent, + # Translators: Button to write the default template to disk + label=_("Write default template…"), + ) + write_btn.Bind(wx.EVT_BUTTON, self._on_write_default) + row.Add(write_btn, 0, wx.LEFT, 4) + + sizer.Add(row, 0, wx.EXPAND | wx.ALL, 5) + + def _on_browse(self, event: wx.Event): + """Open a file dialog to select a template file.""" + with wx.FileDialog( + self._parent, + # Translators: Dialog title when browsing for a template file + _("Select template file"), + wildcard="Mako files (*.mako)|*.mako|All files (*.*)|*.*", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as dlg: + if dlg.ShowModal() == wx.ID_OK: + self._path_ctrl.SetValue(dlg.GetPath()) + + def _on_write_default(self, event: wx.Event): + """Copy the default template to a user-chosen location.""" + with wx.FileDialog( + self._parent, + # Translators: Dialog title when saving the default template to disk + _("Save default template as"), + wildcard="Mako files (*.mako)|*.mako", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as dlg: + if dlg.ShowModal() == wx.ID_OK: + dest = Path(dlg.GetPath()) + src = ( + global_vars.templates_path / self._default_template_filename + ) + shutil.copy(src, dest) + self._path_ctrl.SetValue(str(dest)) + + def get_path(self) -> Path | None: + """Return the current path value, or None if the field is empty. + + Returns: + A Path object if the field has a value, otherwise None. + """ + val = self._path_ctrl.GetValue().strip() + return Path(val) if val else None + + class PreferencesDialog(wx.Dialog): """A dialog to configure the application preferences.""" @@ -314,6 +406,31 @@ def init_ui(self): sizer.Add(server_group_sizer, 0, wx.ALL, 5) + templates_group = wx.StaticBox( + panel, + # Translators: Group label for HTML rendering settings in preferences + label=_("HTML rendering"), + ) + templates_sizer = wx.StaticBoxSizer(templates_group, wx.VERTICAL) + + self.html_message_template_path = TemplatePathWidget( + templates_group, + templates_sizer, + # Translators: Label for the HTML message template path setting + label=_("Message HTML template (.mako):"), + initial_path=conf.templates.html_message_template_path, + default_template_filename="html_message.mako", + ) + self.html_export_template_path = TemplatePathWidget( + templates_group, + templates_sizer, + # Translators: Label for the conversation export template path setting + label=_("Conversation export template (.mako):"), + initial_path=conf.templates.html_export_template_path, + default_template_filename="conversation_export.mako", + ) + sizer.Add(templates_sizer, 0, wx.EXPAND | wx.ALL, 5) + bSizer = wx.BoxSizer(wx.HORIZONTAL) btn = wx.Button(panel, wx.ID_SAVE) From 8f95694c5e2e7ca2ffff33608523d433bffc3912 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:07:46 +0100 Subject: [PATCH 07/13] feat: render system prompt Mako template when applying profile --- .../presenters/base_conversation_presenter.py | 41 ++++ basilisk/views/base_conversation.py | 5 +- .../test_base_conversation_presenter.py | 207 +++--------------- 3 files changed, 74 insertions(+), 179 deletions(-) diff --git a/basilisk/presenters/base_conversation_presenter.py b/basilisk/presenters/base_conversation_presenter.py index 1f495840d..601e2c24b 100644 --- a/basilisk/presenters/base_conversation_presenter.py +++ b/basilisk/presenters/base_conversation_presenter.py @@ -84,6 +84,47 @@ def get_display_models(self, engine: BaseEngine | None) -> list[tuple]: return [] return [m.display_model for m in engine.models] + def render_system_prompt( + self, + profile: config.ConversationProfile, + account: config.Account | None, + model, + ) -> str: + """Render the profile's system_prompt as a Mako template. + + Falls back to the raw string on error, showing a log warning. + + Args: + profile: The conversation profile whose system_prompt to render. + account: Active account (injected as context variable). + model: Active model (injected as context variable). + + Returns: + The rendered prompt string, or the original on template error. + """ + from datetime import datetime + + from basilisk.services.template_service import TemplateService + + template_str = profile.system_prompt or "" + if not template_str: + return template_str + context = { + "now": datetime.now(), + "profile": profile, + "account": account, + "model": model, + } + try: + return TemplateService.render_prompt(template_str, context) + except ValueError: + log.warning( + "Failed to render system prompt template for profile %r", + profile.name, + exc_info=True, + ) + return template_str + def resolve_account_and_model( self, profile: config.ConversationProfile, diff --git a/basilisk/views/base_conversation.py b/basilisk/views/base_conversation.py index d18658a57..019f2941a 100644 --- a/basilisk/views/base_conversation.py +++ b/basilisk/views/base_conversation.py @@ -459,7 +459,10 @@ def apply_profile( log.debug("no profile, select default account") self.select_default_account() return - self.system_prompt_txt.SetValue(profile.system_prompt) + rendered = self.base_conv_presenter.render_system_prompt( + profile, self.current_account, self.current_model + ) + self.system_prompt_txt.SetValue(rendered) self.set_account_and_model_from_profile( profile, fall_back_default_account ) diff --git a/tests/presenters/test_base_conversation_presenter.py b/tests/presenters/test_base_conversation_presenter.py index 5eb50c360..e3f449991 100644 --- a/tests/presenters/test_base_conversation_presenter.py +++ b/tests/presenters/test_base_conversation_presenter.py @@ -1,5 +1,8 @@ """Tests for BaseConversationPresenter.""" +from __future__ import annotations + +from datetime import datetime from unittest.mock import MagicMock import pytest @@ -7,193 +10,41 @@ from basilisk.presenters.base_conversation_presenter import ( BaseConversationPresenter, ) -from basilisk.services.account_model_service import AccountModelService - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def mock_service(): - """Return a mock AccountModelService.""" - return MagicMock(spec=AccountModelService) @pytest.fixture -def presenter(mock_service): +def presenter(): """Return a BaseConversationPresenter with a mock service.""" - return BaseConversationPresenter(account_model_service=mock_service) - - -# --------------------------------------------------------------------------- -# Helpers (used inline for custom state) -# --------------------------------------------------------------------------- - - -def _make_account(display_name: str) -> MagicMock: - """Return a mock Account with the given display name. - - Args: - display_name: The account display name. - - Returns: - A MagicMock account. - """ - account = MagicMock() - account.display_name = display_name - return account - - -def _make_model(display_model: tuple) -> MagicMock: - """Return a mock ProviderAIModel whose display_model is the given tuple. - - Args: - display_model: The display tuple. - - Returns: - A MagicMock model. - """ - model = MagicMock() - model.display_model = display_model - return model - - -# --------------------------------------------------------------------------- -# Initialisation -# --------------------------------------------------------------------------- - - -class TestBaseConversationPresenterInit: - """Tests for BaseConversationPresenter.__init__.""" - - def test_creates_service_when_not_provided(self): - """A new AccountModelService is created when none is passed.""" - p = BaseConversationPresenter() - assert isinstance(p.account_model_service, AccountModelService) - - def test_uses_provided_service(self, mock_service): - """The provided service instance is stored unchanged.""" - p = BaseConversationPresenter(account_model_service=mock_service) - assert p.account_model_service is mock_service - + return BaseConversationPresenter() -# --------------------------------------------------------------------------- -# get_display_accounts() -# --------------------------------------------------------------------------- +class TestRenderSystemPrompt: + """Tests for BaseConversationPresenter.render_system_prompt.""" -class TestGetDisplayAccounts: - """Tests for BaseConversationPresenter.get_display_accounts().""" - - def test_returns_list_of_display_names(self, presenter, mocker): - """Returns a list with each account's display_name.""" - accounts = [_make_account("Alice"), _make_account("Bob")] - mocker.patch("basilisk.config.accounts", return_value=accounts) - result = presenter.get_display_accounts() - assert result == ["Alice", "Bob"] - - def test_empty_when_no_accounts(self, presenter, mocker): - """Returns [] when there are no accounts.""" - mocker.patch("basilisk.config.accounts", return_value=[]) - assert presenter.get_display_accounts() == [] - - def test_force_refresh_calls_reset_active_organization( - self, presenter, mocker - ): - """force_refresh=True calls reset_active_organization on each account.""" - accounts = [_make_account("X"), _make_account("Y")] - mocker.patch("basilisk.config.accounts", return_value=accounts) - presenter.get_display_accounts(force_refresh=True) - for account in accounts: - account.reset_active_organization.assert_called_once() - - def test_no_reset_when_force_refresh_false(self, presenter, mocker): - """force_refresh=False does not call reset_active_organization.""" - accounts = [_make_account("X")] - mocker.patch("basilisk.config.accounts", return_value=accounts) - presenter.get_display_accounts(force_refresh=False) - accounts[0].reset_active_organization.assert_not_called() - - -# --------------------------------------------------------------------------- -# get_display_models() -# --------------------------------------------------------------------------- - - -class TestGetDisplayModels: - """Tests for BaseConversationPresenter.get_display_models().""" - - def test_returns_empty_when_no_engine(self, presenter): - """Returns [] when engine is None.""" - assert presenter.get_display_models(None) == [] - - def test_returns_display_model_tuples(self, presenter): - """Returns display_model for each model in the engine.""" - engine = MagicMock() - engine.models = [ - _make_model(("GPT-4", "Yes", "128k", "4096")), - _make_model(("GPT-3.5", "No", "16k", "2048")), - ] - result = presenter.get_display_models(engine) - assert result == [ - ("GPT-4", "Yes", "128k", "4096"), - ("GPT-3.5", "No", "16k", "2048"), - ] - - def test_returns_empty_when_engine_has_no_models(self, presenter): - """Returns [] when the engine's model list is empty.""" - engine = MagicMock() - engine.models = [] - assert presenter.get_display_models(engine) == [] - - -# --------------------------------------------------------------------------- -# get_engine() -# --------------------------------------------------------------------------- - - -class TestGetEngine: - """Tests for BaseConversationPresenter.get_engine().""" - - def test_delegates_to_service(self, mock_service): - """get_engine() calls account_model_service.get_engine().""" - engine = MagicMock() - mock_service.get_engine.return_value = engine - p = BaseConversationPresenter(account_model_service=mock_service) - account = MagicMock() - result = p.get_engine(account) - mock_service.get_engine.assert_called_once_with(account) - assert result is engine - - -# --------------------------------------------------------------------------- -# resolve_account_and_model() -# --------------------------------------------------------------------------- - + def test_empty_prompt_returns_empty(self, presenter): + """Empty system_prompt returns empty string without calling service.""" + profile = MagicMock() + profile.system_prompt = "" + result = presenter.render_system_prompt(profile, None, None) + assert result == "" -class TestResolveAccountAndModel: - """Tests for BaseConversationPresenter.resolve_account_and_model().""" + def test_plain_text_unchanged(self, presenter): + """Plain text (no Mako) passes through unchanged.""" + profile = MagicMock() + profile.system_prompt = "You are helpful." + result = presenter.render_system_prompt(profile, None, None) + assert result == "You are helpful." - def test_delegates_to_service(self, mock_service): - """resolve_account_and_model() delegates to the service.""" - account = MagicMock() - mock_service.resolve_account_and_model.return_value = (account, "gpt-4") - p = BaseConversationPresenter(account_model_service=mock_service) + def test_mako_variable_rendered(self, presenter): + """Mako ${now} variable is substituted.""" profile = MagicMock() - result = p.resolve_account_and_model( - profile, fall_back_default_account=True - ) - mock_service.resolve_account_and_model.assert_called_once_with( - profile, True - ) - assert result == (account, "gpt-4") + profile.system_prompt = "Date: ${now.year}" + result = presenter.render_system_prompt(profile, None, None) + assert str(datetime.now().year) in result - def test_returns_none_none_when_no_account_or_model(self, mock_service): - """Returns (None, None) when the service returns no account or model.""" - mock_service.resolve_account_and_model.return_value = (None, None) - p = BaseConversationPresenter(account_model_service=mock_service) + def test_invalid_template_falls_back(self, presenter): + """Invalid template logs warning and returns original string.""" profile = MagicMock() - account, model_id = p.resolve_account_and_model(profile) - assert account is None - assert model_id is None + profile.system_prompt = "${unclosed" + result = presenter.render_system_prompt(profile, None, None) + assert result == "${unclosed" From f9bd01be25b6e5987b35fe09ed5606fdf6fd959c Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:22:18 +0100 Subject: [PATCH 08/13] feat: render system prompt Mako template when applying profile --- .../presenters/base_conversation_presenter.py | 15 +- basilisk/services/template_service.py | 30 +++ .../test_base_conversation_presenter.py | 193 +++++++++++++++++- tests/services/test_template_service.py | 29 +++ 4 files changed, 254 insertions(+), 13 deletions(-) diff --git a/basilisk/presenters/base_conversation_presenter.py b/basilisk/presenters/base_conversation_presenter.py index 601e2c24b..a2a1fd38d 100644 --- a/basilisk/presenters/base_conversation_presenter.py +++ b/basilisk/presenters/base_conversation_presenter.py @@ -12,6 +12,7 @@ import basilisk.config as config from basilisk.services.account_model_service import AccountModelService +from basilisk.services.template_service import TemplateService if TYPE_CHECKING: from basilisk.provider_engine.base_engine import BaseEngine @@ -102,21 +103,13 @@ def render_system_prompt( Returns: The rendered prompt string, or the original on template error. """ - from datetime import datetime - - from basilisk.services.template_service import TemplateService - template_str = profile.system_prompt or "" if not template_str: return template_str - context = { - "now": datetime.now(), - "profile": profile, - "account": account, - "model": model, - } try: - return TemplateService.render_prompt(template_str, context) + return TemplateService.render_system_prompt( + template_str, profile, account, model + ) except ValueError: log.warning( "Failed to render system prompt template for profile %r", diff --git a/basilisk/services/template_service.py b/basilisk/services/template_service.py index c6b0cfc35..a87c6fb9e 100644 --- a/basilisk/services/template_service.py +++ b/basilisk/services/template_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import Any @@ -87,6 +88,35 @@ def render_prompt(template_str: str, context: dict[str, Any]) -> str: """ return _render_inline(template_str, context) + @staticmethod + def render_system_prompt( + template_str: str, profile: Any, account: Any | None, model: Any | None + ) -> str: + """Render a system prompt template with standard conversation context. + + Builds the standard context (now, profile, account, model) and + delegates to render_prompt. + + Args: + template_str: Raw template string from ConversationProfile. + profile: The conversation profile (available as 'profile'). + account: Active account (available as 'account'). + model: Active model (available as 'model'). + + Returns: + Rendered plain-text prompt. + + Raises: + ValueError: On Mako syntax or runtime error. + """ + context = { + "now": datetime.now(), + "profile": profile, + "account": account, + "model": model, + } + return _render_inline(template_str, context) + @staticmethod def render_html_message( content: str, title: str, template_path: Path | None diff --git a/tests/presenters/test_base_conversation_presenter.py b/tests/presenters/test_base_conversation_presenter.py index e3f449991..5cc200b20 100644 --- a/tests/presenters/test_base_conversation_presenter.py +++ b/tests/presenters/test_base_conversation_presenter.py @@ -10,12 +10,201 @@ from basilisk.presenters.base_conversation_presenter import ( BaseConversationPresenter, ) +from basilisk.services.account_model_service import AccountModelService + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_service(): + """Return a mock AccountModelService.""" + return MagicMock(spec=AccountModelService) @pytest.fixture -def presenter(): +def presenter(mock_service): """Return a BaseConversationPresenter with a mock service.""" - return BaseConversationPresenter() + return BaseConversationPresenter(account_model_service=mock_service) + + +# --------------------------------------------------------------------------- +# Helpers (used inline for custom state) +# --------------------------------------------------------------------------- + + +def _make_account(display_name: str) -> MagicMock: + """Return a mock Account with the given display name. + + Args: + display_name: The account display name. + + Returns: + A MagicMock account. + """ + account = MagicMock() + account.display_name = display_name + return account + + +def _make_model(display_model: tuple) -> MagicMock: + """Return a mock ProviderAIModel whose display_model is the given tuple. + + Args: + display_model: The display tuple. + + Returns: + A MagicMock model. + """ + model = MagicMock() + model.display_model = display_model + return model + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +class TestBaseConversationPresenterInit: + """Tests for BaseConversationPresenter.__init__.""" + + def test_creates_service_when_not_provided(self): + """A new AccountModelService is created when none is passed.""" + p = BaseConversationPresenter() + assert isinstance(p.account_model_service, AccountModelService) + + def test_uses_provided_service(self, mock_service): + """The provided service instance is stored unchanged.""" + p = BaseConversationPresenter(account_model_service=mock_service) + assert p.account_model_service is mock_service + + +# --------------------------------------------------------------------------- +# get_display_accounts() +# --------------------------------------------------------------------------- + + +class TestGetDisplayAccounts: + """Tests for BaseConversationPresenter.get_display_accounts().""" + + def test_returns_list_of_display_names(self, presenter, mocker): + """Returns a list with each account's display_name.""" + accounts = [_make_account("Alice"), _make_account("Bob")] + mocker.patch("basilisk.config.accounts", return_value=accounts) + result = presenter.get_display_accounts() + assert result == ["Alice", "Bob"] + + def test_empty_when_no_accounts(self, presenter, mocker): + """Returns [] when there are no accounts.""" + mocker.patch("basilisk.config.accounts", return_value=[]) + assert presenter.get_display_accounts() == [] + + def test_force_refresh_calls_reset_active_organization( + self, presenter, mocker + ): + """force_refresh=True calls reset_active_organization on each account.""" + accounts = [_make_account("X"), _make_account("Y")] + mocker.patch("basilisk.config.accounts", return_value=accounts) + presenter.get_display_accounts(force_refresh=True) + for account in accounts: + account.reset_active_organization.assert_called_once() + + def test_no_reset_when_force_refresh_false(self, presenter, mocker): + """force_refresh=False does not call reset_active_organization.""" + accounts = [_make_account("X")] + mocker.patch("basilisk.config.accounts", return_value=accounts) + presenter.get_display_accounts(force_refresh=False) + accounts[0].reset_active_organization.assert_not_called() + + +# --------------------------------------------------------------------------- +# get_display_models() +# --------------------------------------------------------------------------- + + +class TestGetDisplayModels: + """Tests for BaseConversationPresenter.get_display_models().""" + + def test_returns_empty_when_no_engine(self, presenter): + """Returns [] when engine is None.""" + assert presenter.get_display_models(None) == [] + + def test_returns_display_model_tuples(self, presenter): + """Returns display_model for each model in the engine.""" + engine = MagicMock() + engine.models = [ + _make_model(("GPT-4", "Yes", "128k", "4096")), + _make_model(("GPT-3.5", "No", "16k", "2048")), + ] + result = presenter.get_display_models(engine) + assert result == [ + ("GPT-4", "Yes", "128k", "4096"), + ("GPT-3.5", "No", "16k", "2048"), + ] + + def test_returns_empty_when_engine_has_no_models(self, presenter): + """Returns [] when the engine's model list is empty.""" + engine = MagicMock() + engine.models = [] + assert presenter.get_display_models(engine) == [] + + +# --------------------------------------------------------------------------- +# get_engine() +# --------------------------------------------------------------------------- + + +class TestGetEngine: + """Tests for BaseConversationPresenter.get_engine().""" + + def test_delegates_to_service(self, mock_service): + """get_engine() calls account_model_service.get_engine().""" + engine = MagicMock() + mock_service.get_engine.return_value = engine + p = BaseConversationPresenter(account_model_service=mock_service) + account = MagicMock() + result = p.get_engine(account) + mock_service.get_engine.assert_called_once_with(account) + assert result is engine + + +# --------------------------------------------------------------------------- +# resolve_account_and_model() +# --------------------------------------------------------------------------- + + +class TestResolveAccountAndModel: + """Tests for BaseConversationPresenter.resolve_account_and_model().""" + + def test_delegates_to_service(self, mock_service): + """resolve_account_and_model() delegates to the service.""" + account = MagicMock() + mock_service.resolve_account_and_model.return_value = (account, "gpt-4") + p = BaseConversationPresenter(account_model_service=mock_service) + profile = MagicMock() + result = p.resolve_account_and_model( + profile, fall_back_default_account=True + ) + mock_service.resolve_account_and_model.assert_called_once_with( + profile, True + ) + assert result == (account, "gpt-4") + + def test_returns_none_none_when_no_account_or_model(self, mock_service): + """Returns (None, None) when the service returns no account or model.""" + mock_service.resolve_account_and_model.return_value = (None, None) + p = BaseConversationPresenter(account_model_service=mock_service) + profile = MagicMock() + account, model_id = p.resolve_account_and_model(profile) + assert account is None + assert model_id is None + + +# --------------------------------------------------------------------------- +# render_system_prompt() +# --------------------------------------------------------------------------- class TestRenderSystemPrompt: diff --git a/tests/services/test_template_service.py b/tests/services/test_template_service.py index 68d50f687..0b296fe16 100644 --- a/tests/services/test_template_service.py +++ b/tests/services/test_template_service.py @@ -87,6 +87,35 @@ def test_missing_custom_template_falls_back_to_default(self, tmp_path): assert "body" in result +class TestRenderSystemPrompt: + """Tests for TemplateService.render_system_prompt.""" + + def test_injects_standard_context(self): + """profile, account, model and now are available in the template.""" + profile = MagicMock() + profile.name = "TestProfile" + account = MagicMock() + account.name = "TestAccount" + result = TemplateService.render_system_prompt( + "${profile.name}/${account.name}", profile, account, None + ) + assert result == "TestProfile/TestAccount" + + def test_now_available(self): + """now variable is injected and usable.""" + from datetime import datetime + + result = TemplateService.render_system_prompt( + "${now.year}", None, None, None + ) + assert str(datetime.now().year) in result + + def test_error_raises_value_error(self): + """Template error raises ValueError.""" + with pytest.raises(ValueError, match="template"): + TemplateService.render_system_prompt("${unclosed", None, None, None) + + class TestRenderConversationExport: """Tests for TemplateService.render_conversation_export.""" From 5c4f648842f615aaaf8c5c226d19782c616cdfa3 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:22:33 +0100 Subject: [PATCH 09/13] feat: add system prompt preview button to profile editor --- .../conversation_profile_presenter.py | 19 ++++++++++++++++++ basilisk/views/conversation_profile_dialog.py | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/basilisk/presenters/conversation_profile_presenter.py b/basilisk/presenters/conversation_profile_presenter.py index 57c6ba191..1f385cb8a 100644 --- a/basilisk/presenters/conversation_profile_presenter.py +++ b/basilisk/presenters/conversation_profile_presenter.py @@ -13,6 +13,7 @@ from basilisk.config import ConversationProfile from basilisk.presenters.presenter_mixins import ManagerCrudMixin +from basilisk.services.template_service import TemplateService if TYPE_CHECKING: from basilisk.config.conversation_profile import ConversationProfileManager @@ -41,6 +42,24 @@ def __init__(self, view, profile: ConversationProfile | None = None): self.view = view self.profile = profile + def preview_system_prompt(self) -> str: + """Render the current system_prompt field as a Mako template preview. + + Returns: + The rendered string, or an error message if rendering fails. + """ + template_str = self.view.system_prompt_txt.GetValue() + try: + return TemplateService.render_system_prompt( + template_str, + self.profile, + self.view.current_account, + self.view.current_model, + ) + except ValueError as exc: + # Translators: Error message shown in the system prompt preview + return _("Template error: {error}").format(error=exc) + def validate_and_build_profile(self) -> ConversationProfile | None: """Validate inputs and build a ConversationProfile. diff --git a/basilisk/views/conversation_profile_dialog.py b/basilisk/views/conversation_profile_dialog.py index 8ed898b91..2c07bd16d 100644 --- a/basilisk/views/conversation_profile_dialog.py +++ b/basilisk/views/conversation_profile_dialog.py @@ -75,6 +75,15 @@ def init_ui(self): label = self.create_system_prompt_widget() self.sizer.Add(label, 0, wx.ALL, 5) self.sizer.Add(self.system_prompt_txt, 0, wx.ALL | wx.EXPAND, 5) + self.preview_prompt_btn = wx.Button( + self, + # Translators: Button to preview the rendered system prompt template + label=_("&Preview system prompt"), + ) + self.sizer.Add(self.preview_prompt_btn, 0, wx.ALL, 5) + self.Bind( + wx.EVT_BUTTON, self._on_preview_prompt, self.preview_prompt_btn + ) label = self.create_model_widget() self.sizer.Add(label, 0, wx.ALL, 5) self.sizer.Add(self.model_list, 0, wx.ALL | wx.EXPAND, 5) @@ -116,6 +125,17 @@ def apply_profile( if profile.account or profile.ai_model_info: self.include_account_checkbox.SetValue(profile.account is not None) + def _on_preview_prompt(self, event: wx.Event): + """Show the rendered system prompt in a message dialog.""" + rendered = self.presenter.preview_system_prompt() + wx.MessageBox( + rendered, + # Translators: Title for the system prompt preview dialog + _("System prompt preview"), + wx.OK | wx.ICON_INFORMATION, + self, + ) + def on_ok(self, event: wx.Event | None): """Handle the OK button click by delegating to the presenter. From d597c434b92c1c0867f32580d0166090bf392424 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:40:47 +0100 Subject: [PATCH 10/13] feat: add Export conversation as HTML menu action --- basilisk/presenters/conversation_presenter.py | 23 ++++++++ basilisk/presenters/main_frame_presenter.py | 17 ++++++ basilisk/views/main_frame.py | 10 ++++ tests/presenters/test_conversation_export.py | 56 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 tests/presenters/test_conversation_export.py diff --git a/basilisk/presenters/conversation_presenter.py b/basilisk/presenters/conversation_presenter.py index cda6e7fae..7bca6b9a5 100644 --- a/basilisk/presenters/conversation_presenter.py +++ b/basilisk/presenters/conversation_presenter.py @@ -28,6 +28,7 @@ from basilisk.provider_ai_model import AIModelInfo from basilisk.provider_capability import ProviderCapability from basilisk.services.conversation_service import ConversationService +from basilisk.services.template_service import TemplateService from basilisk.sound_manager import play_sound, stop_sound if TYPE_CHECKING: @@ -143,6 +144,28 @@ def on_stop_completion(self): """Stop the current completion.""" self.completion_handler.stop_completion() + def export_to_html(self, path: str) -> None: + """Export the current conversation to an HTML file. + + Args: + path: Destination file path for the HTML export. + """ + template_path = config.conf().templates.html_export_template_path + profile = getattr(self.view, "current_profile", None) + html = TemplateService.render_conversation_export( + self.conversation, profile, template_path + ) + try: + with open(path, "w", encoding="utf-8") as f: + f.write(html) + except OSError as exc: + self.view.show_error( + # Translators: Error shown when conversation export fails + _("Failed to export conversation: {error}").format(error=exc), + # Translators: Title for the export error dialog + _("Export error"), + ) + def get_system_message(self) -> SystemMessage | None: """Get the system message from the view's system prompt input.""" system_prompt = self.view.system_prompt_txt.GetValue() diff --git a/basilisk/presenters/main_frame_presenter.py b/basilisk/presenters/main_frame_presenter.py index 18656b518..7a9744534 100644 --- a/basilisk/presenters/main_frame_presenter.py +++ b/basilisk/presenters/main_frame_presenter.py @@ -269,6 +269,23 @@ def save_conversation_as(self, file_path: str) -> bool: current_tab.bskc_path = file_path return success + def export_conversation(self) -> None: + """Export the current conversation to an HTML file chosen by the user.""" + tab = self.view.current_tab + if not tab: + return + title = tab.GetLabel() or _("conversation") + with wx.FileDialog( + self.view, + # Translators: Dialog title when exporting a conversation to HTML + _("Export conversation as HTML"), + defaultFile=f"{title}.html", + wildcard="HTML files (*.html)|*.html", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as dlg: + if dlg.ShowModal() == wx.ID_OK: + tab.presenter.export_to_html(dlg.GetPath()) + # -- Name conversation -- def name_conversation(self, auto: bool = False): diff --git a/basilisk/views/main_frame.py b/basilisk/views/main_frame.py index 25c0b5453..6d8fd9573 100644 --- a/basilisk/views/main_frame.py +++ b/basilisk/views/main_frame.py @@ -187,6 +187,12 @@ def update_item_label_suffix(item: wx.MenuItem, suffix: str = "..."): lambda e: self.on_transcribe_audio(e, False), transcribe_audio_file_item, ) + export_html_item = conversation_menu.Append( + wx.ID_ANY, + # Translators: Menu item to export the current conversation as HTML + _("Export conversation as &HTML") + "...", + ) + self.Bind(wx.EVT_MENU, self._on_export_conversation, export_html_item) conversation_menu.AppendSeparator() quit_item = conversation_menu.Append(wx.ID_EXIT) self.Bind(wx.EVT_MENU, self.on_quit, quit_item) @@ -485,6 +491,10 @@ def on_save_conversation(self, event: wx.Event | None): """ self.presenter.save_current_conversation() + def _on_export_conversation(self, event: wx.Event): + """Handle export conversation as HTML menu action.""" + self.presenter.export_conversation() + def on_save_as_conversation(self, event: wx.Event | None) -> Optional[str]: """Save the current conversation to a user-specified path. diff --git a/tests/presenters/test_conversation_export.py b/tests/presenters/test_conversation_export.py new file mode 100644 index 000000000..811d54c0f --- /dev/null +++ b/tests/presenters/test_conversation_export.py @@ -0,0 +1,56 @@ +"""Tests for ConversationPresenter.export_to_html.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from basilisk.conversation import Conversation +from basilisk.presenters.conversation_presenter import ConversationPresenter +from basilisk.services.conversation_service import ConversationService + + +@pytest.fixture +def mock_view(conversation_view_base): + """Return a mock view with current_profile set to None.""" + conversation_view_base.current_profile = None + return conversation_view_base + + +@pytest.fixture +def presenter(mock_view): + """Return a ConversationPresenter with minimal mocks.""" + service = MagicMock(spec=ConversationService) + return ConversationPresenter( + view=mock_view, + service=service, + conversation=Conversation(), + conv_storage_path="memory://test", + ) + + +class TestExportToHtml: + """Tests for ConversationPresenter.export_to_html.""" + + def test_writes_html_file(self, presenter, tmp_path, mocker): + """export_to_html writes the rendered HTML to disk.""" + mocker.patch( + "basilisk.services.template_service.TemplateService" + ".render_conversation_export", + return_value="test", + ) + out = tmp_path / "export.html" + presenter.export_to_html(str(out)) + assert out.read_text(encoding="utf-8") == "test" + + def test_write_error_shows_error(self, presenter, mocker): + """OSError during write calls view.show_error.""" + mocker.patch( + "basilisk.services.template_service.TemplateService" + ".render_conversation_export", + return_value="", + ) + mocker.patch("builtins.open", side_effect=OSError("disk full")) + presenter.export_to_html("some/path.html") + presenter.view.show_error.assert_called_once() From 0e5cd9f27babfe770161abd483e0280b85605bfa Mon Sep 17 00:00:00 2001 From: clementb49 Date: Sun, 8 Mar 2026 20:42:55 +0100 Subject: [PATCH 11/13] fix: capitalize docstring and move datetime import to module level in tests --- tests/services/test_template_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/services/test_template_service.py b/tests/services/test_template_service.py index 0b296fe16..0f19e79af 100644 --- a/tests/services/test_template_service.py +++ b/tests/services/test_template_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from unittest.mock import MagicMock import pytest @@ -102,9 +103,7 @@ def test_injects_standard_context(self): assert result == "TestProfile/TestAccount" def test_now_available(self): - """now variable is injected and usable.""" - from datetime import datetime - + """Now variable is injected and usable.""" result = TemplateService.render_system_prompt( "${now.year}", None, None, None ) From 99267151a3048b7d57added202be086a41f72d4a Mon Sep 17 00:00:00 2001 From: clementb49 Date: Tue, 10 Mar 2026 04:06:05 +0100 Subject: [PATCH 12/13] refactor: move file-dialog save/export logic to ConversationTab - Add _ask_save_path() helper to ConversationTab to factorize wx.FileDialog for save operations (wildcard, defaultFile, FD_SAVE | FD_OVERWRITE_PROMPT) - Add ask_save_as() and ask_export_html() to ConversationTab; MainFrame delegates to these instead of owning the dialogs - Remove save_conversation_as() and export_conversation() from MainFramePresenter (wx.FileDialog no longer in presenter layer) - Simplify save_current_conversation() and on_save_as_conversation() now that current_tab is always non-None Co-Authored-By: Claude Sonnet 4.6 --- basilisk/presenters/main_frame_presenter.py | 37 ------------- basilisk/views/conversation_tab.py | 52 +++++++++++++++++++ basilisk/views/main_frame.py | 25 +-------- tests/presenters/test_main_frame_presenter.py | 21 -------- 4 files changed, 54 insertions(+), 81 deletions(-) diff --git a/basilisk/presenters/main_frame_presenter.py b/basilisk/presenters/main_frame_presenter.py index 7a9744534..c10826d25 100644 --- a/basilisk/presenters/main_frame_presenter.py +++ b/basilisk/presenters/main_frame_presenter.py @@ -244,48 +244,11 @@ def save_current_conversation(self): If no file path is set, triggers save-as via the view. """ current_tab = self.view.current_tab - if not current_tab: - self.view.show_error(_("No conversation selected"), _("Error")) - return if not current_tab.bskc_path: self.view.on_save_as_conversation(None) return current_tab.save_conversation(current_tab.bskc_path) - def save_conversation_as(self, file_path: str) -> bool: - """Save the current conversation to a specified file path. - - Args: - file_path: The target file path. - - Returns: - True if saved successfully. - """ - current_tab = self.view.current_tab - if not current_tab: - return False - success = current_tab.save_conversation(file_path) - if success: - current_tab.bskc_path = file_path - return success - - def export_conversation(self) -> None: - """Export the current conversation to an HTML file chosen by the user.""" - tab = self.view.current_tab - if not tab: - return - title = tab.GetLabel() or _("conversation") - with wx.FileDialog( - self.view, - # Translators: Dialog title when exporting a conversation to HTML - _("Export conversation as HTML"), - defaultFile=f"{title}.html", - wildcard="HTML files (*.html)|*.html", - style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, - ) as dlg: - if dlg.ShowModal() == wx.ID_OK: - tab.presenter.export_to_html(dlg.GetPath()) - # -- Name conversation -- def name_conversation(self, auto: bool = False): diff --git a/basilisk/views/conversation_tab.py b/basilisk/views/conversation_tab.py index 66dfddf51..ef6cc5820 100644 --- a/basilisk/views/conversation_tab.py +++ b/basilisk/views/conversation_tab.py @@ -587,6 +587,58 @@ def save_conversation(self, file_path: str) -> bool: """ return self.presenter.save_conversation(file_path) + def _ask_save_path( + self, message: str, wildcard: str, default_file: str = "" + ) -> str | None: + """Show a save file dialog and return the chosen path. + + Args: + message: Dialog title. + wildcard: File type filter string. + default_file: Pre-filled filename suggestion. + + Returns: + The selected path, or None if the user cancelled. + """ + with wx.FileDialog( + self, + message=message, + defaultFile=default_file, + wildcard=wildcard, + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as dlg: + if dlg.ShowModal() == wx.ID_OK: + return dlg.GetPath() + return None + + def ask_save_as(self) -> str | None: + """Show a save dialog for .bskc and persist on confirmation. + + Returns: + The saved file path, or None if cancelled or save failed. + """ + path = self._ask_save_path( + # Translators: Title of the save-conversation-as dialog + _("Save conversation"), + _("Basilisk conversation files") + "(*.bskc)|*.bskc", + ) + if path and self.save_conversation(path): + self.bskc_path = path + return path + return None + + def ask_export_html(self) -> None: + """Show a save dialog for .html and export the conversation.""" + default = f"{self.GetLabel() or _('conversation')}.html" + path = self._ask_save_path( + # Translators: Dialog title when exporting a conversation to HTML + _("Export conversation as HTML"), + "HTML files (*.html)|*.html", + default_file=default, + ) + if path: + self.presenter.export_to_html(path) + def remove_message_block(self, message_block: MessageBlock): """Remove a message block from the conversation. diff --git a/basilisk/views/main_frame.py b/basilisk/views/main_frame.py index 6d8fd9573..858079e15 100644 --- a/basilisk/views/main_frame.py +++ b/basilisk/views/main_frame.py @@ -493,7 +493,7 @@ def on_save_conversation(self, event: wx.Event | None): def _on_export_conversation(self, event: wx.Event): """Handle export conversation as HTML menu action.""" - self.presenter.export_conversation() + self.current_tab.ask_export_html() def on_save_as_conversation(self, event: wx.Event | None) -> Optional[str]: """Save the current conversation to a user-specified path. @@ -504,28 +504,7 @@ def on_save_as_conversation(self, event: wx.Event | None) -> Optional[str]: Returns: The file path if saved successfully, or None. """ - current_tab = self.current_tab - if not current_tab: - wx.MessageBox( - _("No conversation selected"), _("Error"), wx.OK | wx.ICON_ERROR - ) - return None - file_dialog = wx.FileDialog( - self, - # Translators: A title for the save conversation dialog - message=_("Save conversation"), - wildcard=_("Basilisk conversation files") + "(*.bskc)|*.bskc", - style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, - ) - file_path = None - if file_dialog.ShowModal() == wx.ID_OK: - file_path = file_dialog.GetPath() - if self.presenter.save_conversation_as(file_path): - file_dialog.Destroy() - return file_path - file_path = None - file_dialog.Destroy() - return file_path + return self.current_tab.ask_save_as() def on_close_conversation(self, event: wx.Event | None): """Close the current conversation tab. diff --git a/tests/presenters/test_main_frame_presenter.py b/tests/presenters/test_main_frame_presenter.py index a80197f2d..f6ea03896 100644 --- a/tests/presenters/test_main_frame_presenter.py +++ b/tests/presenters/test_main_frame_presenter.py @@ -273,27 +273,6 @@ def test_saves_when_path_exists(self, presenter, mock_view): ) -class TestSaveConversationAs: - """Tests for save_conversation_as.""" - - def test_saves_and_updates_path(self, presenter, mock_view): - """Should save and update bskc_path on success.""" - mock_view.current_tab.save_conversation.return_value = True - - result = presenter.save_conversation_as("/new/path.bskc") - - assert result is True - assert mock_view.current_tab.bskc_path == "/new/path.bskc" - - def test_returns_false_on_failure(self, presenter, mock_view): - """Should return False on save failure.""" - mock_view.current_tab.save_conversation.return_value = False - - result = presenter.save_conversation_as("/new/path.bskc") - - assert result is False - - class TestHandleNoAccountConfigured: """Tests for handle_no_account_configured.""" From 64d06db349a40a0985403f0fd2fcb3d00ceed908 Mon Sep 17 00:00:00 2001 From: clementb49 Date: Tue, 10 Mar 2026 04:51:04 +0100 Subject: [PATCH 13/13] fix: address code review findings on mako template PR - base_conversation_presenter: annotate model param as ProviderAIModel | None - conversation_presenter: wrap render + file write in single try/except (OSError, ValueError) so broken templates surface via show_error - conversation_profile_presenter: preview_system_prompt builds profile from current form state via validate_and_build_profile() instead of using the possibly-None/stale self.profile - base_conversation.py: render system prompt after set_account_and_model_from_profile so template context reflects the profile's resolved account/model - preferences_dialog: TemplatePathWidget becomes wx.Panel + ErrorDisplayMixin; PreferencesDialog inherits ErrorDisplayMixin; _on_write_default catches OSError and calls show_error() instead of crashing - preferences_presenter: wrap conf.save() in try/except (OSError, ValidationError) and show error without closing dialog on failure Co-Authored-By: Claude Sonnet 4.6 --- .../presenters/base_conversation_presenter.py | 5 ++- basilisk/presenters/conversation_presenter.py | 8 ++-- .../conversation_profile_presenter.py | 8 +++- basilisk/presenters/preferences_presenter.py | 13 +++++- basilisk/views/base_conversation.py | 6 +-- basilisk/views/preferences_dialog.py | 45 ++++++++++++------- tests/presenters/test_conversation_export.py | 10 +++++ .../presenters/test_preferences_presenter.py | 39 ++++++++++++++++ 8 files changed, 107 insertions(+), 27 deletions(-) diff --git a/basilisk/presenters/base_conversation_presenter.py b/basilisk/presenters/base_conversation_presenter.py index a2a1fd38d..b380f7083 100644 --- a/basilisk/presenters/base_conversation_presenter.py +++ b/basilisk/presenters/base_conversation_presenter.py @@ -15,6 +15,7 @@ from basilisk.services.template_service import TemplateService if TYPE_CHECKING: + from basilisk.provider_ai_model import ProviderAIModel from basilisk.provider_engine.base_engine import BaseEngine log = logging.getLogger(__name__) @@ -89,7 +90,7 @@ def render_system_prompt( self, profile: config.ConversationProfile, account: config.Account | None, - model, + model: ProviderAIModel | None, ) -> str: """Render the profile's system_prompt as a Mako template. @@ -98,7 +99,7 @@ def render_system_prompt( Args: profile: The conversation profile whose system_prompt to render. account: Active account (injected as context variable). - model: Active model (injected as context variable). + model: Active AI model (injected as context variable). Returns: The rendered prompt string, or the original on template error. diff --git a/basilisk/presenters/conversation_presenter.py b/basilisk/presenters/conversation_presenter.py index 7bca6b9a5..e5d516ae9 100644 --- a/basilisk/presenters/conversation_presenter.py +++ b/basilisk/presenters/conversation_presenter.py @@ -152,13 +152,13 @@ def export_to_html(self, path: str) -> None: """ template_path = config.conf().templates.html_export_template_path profile = getattr(self.view, "current_profile", None) - html = TemplateService.render_conversation_export( - self.conversation, profile, template_path - ) try: + html = TemplateService.render_conversation_export( + self.conversation, profile, template_path + ) with open(path, "w", encoding="utf-8") as f: f.write(html) - except OSError as exc: + except (OSError, ValueError) as exc: self.view.show_error( # Translators: Error shown when conversation export fails _("Failed to export conversation: {error}").format(error=exc), diff --git a/basilisk/presenters/conversation_profile_presenter.py b/basilisk/presenters/conversation_profile_presenter.py index 1f385cb8a..59303ceb1 100644 --- a/basilisk/presenters/conversation_profile_presenter.py +++ b/basilisk/presenters/conversation_profile_presenter.py @@ -45,14 +45,20 @@ def __init__(self, view, profile: ConversationProfile | None = None): def preview_system_prompt(self) -> str: """Render the current system_prompt field as a Mako template preview. + Builds the profile from the current form state via + validate_and_build_profile so that template variables such as + ``profile.name`` reflect the unsaved state rather than the + (possibly None or stale) stored profile. + Returns: The rendered string, or an error message if rendering fails. """ template_str = self.view.system_prompt_txt.GetValue() + preview_profile = self.validate_and_build_profile() or self.profile try: return TemplateService.render_system_prompt( template_str, - self.profile, + preview_profile, self.view.current_account, self.view.current_model, ) diff --git a/basilisk/presenters/preferences_presenter.py b/basilisk/presenters/preferences_presenter.py index 4256f0c05..283a77da6 100644 --- a/basilisk/presenters/preferences_presenter.py +++ b/basilisk/presenters/preferences_presenter.py @@ -7,6 +7,8 @@ import logging +from pydantic import ValidationError + import basilisk.config as config from basilisk.config import ( AutomaticUpdateModeEnum, @@ -124,7 +126,16 @@ def on_ok(self) -> None: self.view.html_export_template_path.get_path() ) - conf.save() + try: + conf.save() + except (OSError, ValidationError) as exc: + self.view.show_error( + # Translators: Error shown when saving preferences fails + _("Failed to save preferences: {error}").format(error=exc), + # Translators: Title for the preferences save error dialog + _("Save error"), + ) + return set_log_level(conf.general.log_level.name) self.view.EndModal(wx.ID_OK) diff --git a/basilisk/views/base_conversation.py b/basilisk/views/base_conversation.py index 019f2941a..3dfb18d20 100644 --- a/basilisk/views/base_conversation.py +++ b/basilisk/views/base_conversation.py @@ -459,13 +459,13 @@ def apply_profile( log.debug("no profile, select default account") self.select_default_account() return + self.set_account_and_model_from_profile( + profile, fall_back_default_account + ) rendered = self.base_conv_presenter.render_system_prompt( profile, self.current_account, self.current_model ) self.system_prompt_txt.SetValue(rendered) - self.set_account_and_model_from_profile( - profile, fall_back_default_account - ) if profile.max_tokens is not None: self.max_tokens_spin_ctrl.SetValue(profile.max_tokens) if profile.temperature is not None: diff --git a/basilisk/views/preferences_dialog.py b/basilisk/views/preferences_dialog.py index ddec65d6b..b1ea1621a 100644 --- a/basilisk/views/preferences_dialog.py +++ b/basilisk/views/preferences_dialog.py @@ -14,44 +14,45 @@ RELEASE_CHANNELS, PreferencesPresenter, ) +from basilisk.views.view_mixins import ErrorDisplayMixin log = logging.getLogger(__name__) -class TemplatePathWidget: - """A label + text field + Browse + Write-default-template buttons group.""" +class TemplatePathWidget(wx.Panel, ErrorDisplayMixin): + """A composite panel: label, path field, Browse and Write-default buttons.""" def __init__( self, parent: wx.Window, - sizer: wx.Sizer, label: str, initial_path, default_template_filename: str, ): - """Create the widget group. + """Create the widget panel. Args: parent: The parent window. - sizer: The sizer to add widgets to. label: Label text displayed above the path field. initial_path: Initial Path value (or None). default_template_filename: Filename in the templates resource dir to copy when the user clicks "Write default template". """ + super().__init__(parent) self._default_template_filename = default_template_filename - self._parent = parent - lbl = wx.StaticText(parent, label=label) + + sizer = wx.BoxSizer(wx.VERTICAL) + lbl = wx.StaticText(self, label=label) sizer.Add(lbl, 0, wx.ALL, 5) row = wx.BoxSizer(wx.HORIZONTAL) self._path_ctrl = wx.TextCtrl( - parent, value=str(initial_path) if initial_path else "" + self, value=str(initial_path) if initial_path else "" ) row.Add(self._path_ctrl, 1, wx.EXPAND | wx.RIGHT, 4) browse_btn = wx.Button( - parent, + self, # Translators: Button to browse for a file path label=_("Browse…"), ) @@ -59,7 +60,7 @@ def __init__( row.Add(browse_btn, 0) write_btn = wx.Button( - parent, + self, # Translators: Button to write the default template to disk label=_("Write default template…"), ) @@ -67,11 +68,12 @@ def __init__( row.Add(write_btn, 0, wx.LEFT, 4) sizer.Add(row, 0, wx.EXPAND | wx.ALL, 5) + self.SetSizer(sizer) def _on_browse(self, event: wx.Event): """Open a file dialog to select a template file.""" with wx.FileDialog( - self._parent, + self, # Translators: Dialog title when browsing for a template file _("Select template file"), wildcard="Mako files (*.mako)|*.mako|All files (*.*)|*.*", @@ -83,7 +85,7 @@ def _on_browse(self, event: wx.Event): def _on_write_default(self, event: wx.Event): """Copy the default template to a user-chosen location.""" with wx.FileDialog( - self._parent, + self, # Translators: Dialog title when saving the default template to disk _("Save default template as"), wildcard="Mako files (*.mako)|*.mako", @@ -94,7 +96,14 @@ def _on_write_default(self, event: wx.Event): src = ( global_vars.templates_path / self._default_template_filename ) - shutil.copy(src, dest) + try: + shutil.copy(src, dest) + except OSError as exc: + self.show_error( + # Translators: Error shown when copying the default template fails + _("Failed to write template: {error}").format(error=exc) + ) + return self._path_ctrl.SetValue(str(dest)) def get_path(self) -> Path | None: @@ -107,7 +116,7 @@ def get_path(self) -> Path | None: return Path(val) if val else None -class PreferencesDialog(wx.Dialog): +class PreferencesDialog(wx.Dialog, ErrorDisplayMixin): """A dialog to configure the application preferences.""" def __init__( @@ -415,20 +424,24 @@ def init_ui(self): self.html_message_template_path = TemplatePathWidget( templates_group, - templates_sizer, # Translators: Label for the HTML message template path setting label=_("Message HTML template (.mako):"), initial_path=conf.templates.html_message_template_path, default_template_filename="html_message.mako", ) + templates_sizer.Add( + self.html_message_template_path, 0, wx.EXPAND | wx.ALL, 5 + ) self.html_export_template_path = TemplatePathWidget( templates_group, - templates_sizer, # Translators: Label for the conversation export template path setting label=_("Conversation export template (.mako):"), initial_path=conf.templates.html_export_template_path, default_template_filename="conversation_export.mako", ) + templates_sizer.Add( + self.html_export_template_path, 0, wx.EXPAND | wx.ALL, 5 + ) sizer.Add(templates_sizer, 0, wx.EXPAND | wx.ALL, 5) bSizer = wx.BoxSizer(wx.HORIZONTAL) diff --git a/tests/presenters/test_conversation_export.py b/tests/presenters/test_conversation_export.py index 811d54c0f..bb1c87a36 100644 --- a/tests/presenters/test_conversation_export.py +++ b/tests/presenters/test_conversation_export.py @@ -54,3 +54,13 @@ def test_write_error_shows_error(self, presenter, mocker): mocker.patch("builtins.open", side_effect=OSError("disk full")) presenter.export_to_html("some/path.html") presenter.view.show_error.assert_called_once() + + def test_template_error_shows_error(self, presenter, mocker): + """ValueError from render_conversation_export calls view.show_error.""" + mocker.patch( + "basilisk.services.template_service.TemplateService" + ".render_conversation_export", + side_effect=ValueError("bad template"), + ) + presenter.export_to_html("some/path.html") + presenter.view.show_error.assert_called_once() diff --git a/tests/presenters/test_preferences_presenter.py b/tests/presenters/test_preferences_presenter.py index 129402d92..c07a1835d 100644 --- a/tests/presenters/test_preferences_presenter.py +++ b/tests/presenters/test_preferences_presenter.py @@ -137,3 +137,42 @@ def test_calls_set_log_level(self, mock_view, make_presenter, mocker): presenter.on_ok() mock_set_log.assert_called_once_with(mock_conf.general.log_level.name) + + def test_oserror_on_save_shows_error_and_keeps_dialog_open( + self, mock_view, make_presenter, mocker + ): + """OSError from conf.save() shows error and does not close the dialog.""" + mock_wx = MagicMock() + mocker.patch.dict(sys.modules, {"wx": mock_wx}) + mocker.patch("basilisk.presenters.preferences_presenter.set_log_level") + presenter, mock_conf = make_presenter(view=mock_view) + mock_conf.save.side_effect = OSError("disk full") + + presenter.on_ok() + + mock_view.show_error.assert_called_once() + mock_view.EndModal.assert_not_called() + + def test_validation_error_on_save_shows_error_and_keeps_dialog_open( + self, mock_view, make_presenter, mocker + ): + """ValidationError from conf.save() shows error and does not close dialog.""" + from pydantic import BaseModel, ValidationError + + mock_wx = MagicMock() + mocker.patch.dict(sys.modules, {"wx": mock_wx}) + mocker.patch("basilisk.presenters.preferences_presenter.set_log_level") + presenter, mock_conf = make_presenter(view=mock_view) + + class _M(BaseModel): + x: int + + try: + _M(x="bad") + except ValidationError as exc: + mock_conf.save.side_effect = exc + + presenter.on_ok() + + mock_view.show_error.assert_called_once() + mock_view.EndModal.assert_not_called()