diff --git a/.gitignore b/.gitignore
index c6cf4399..3cecd4d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ basilisk_config.yml
*.mo
user_data/
coverage.xml
+.worktrees/
diff --git a/basilisk/config/__init__.py b/basilisk/config/__init__.py
index ed1de433..19776dd6 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 4e2a778c..6f65a77f 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/global_vars.py b/basilisk/global_vars.py
index 868553b4..6edece71 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/presenters/base_conversation_presenter.py b/basilisk/presenters/base_conversation_presenter.py
index 1f495840..b380f708 100644
--- a/basilisk/presenters/base_conversation_presenter.py
+++ b/basilisk/presenters/base_conversation_presenter.py
@@ -12,8 +12,10 @@
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_ai_model import ProviderAIModel
from basilisk.provider_engine.base_engine import BaseEngine
log = logging.getLogger(__name__)
@@ -84,6 +86,39 @@ 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: ProviderAIModel | None,
+ ) -> 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 AI model (injected as context variable).
+
+ Returns:
+ The rendered prompt string, or the original on template error.
+ """
+ template_str = profile.system_prompt or ""
+ if not template_str:
+ return template_str
+ try:
+ return TemplateService.render_system_prompt(
+ template_str, profile, account, model
+ )
+ 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/presenters/conversation_presenter.py b/basilisk/presenters/conversation_presenter.py
index cda6e7fa..e5d516ae 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)
+ 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, ValueError) 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/conversation_profile_presenter.py b/basilisk/presenters/conversation_profile_presenter.py
index 57c6ba19..59303ceb 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,30 @@ 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.
+
+ 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,
+ preview_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/presenters/main_frame_presenter.py b/basilisk/presenters/main_frame_presenter.py
index 18656b51..c10826d2 100644
--- a/basilisk/presenters/main_frame_presenter.py
+++ b/basilisk/presenters/main_frame_presenter.py
@@ -244,31 +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
-
# -- Name conversation --
def name_conversation(self, auto: bool = False):
diff --git a/basilisk/presenters/preferences_presenter.py b/basilisk/presenters/preferences_presenter.py
index c22eecb4..283a77da 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,
@@ -117,8 +119,23 @@ 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()
+ 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/res/templates/conversation_export.mako b/basilisk/res/templates/conversation_export.mako
new file mode 100644
index 00000000..4734b7df
--- /dev/null
+++ b/basilisk/res/templates/conversation_export.mako
@@ -0,0 +1,73 @@
+
+
+
+
+ ${conversation.title or _("Conversation") | h}
+
+
+
+
+
+% 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:
+
+ % 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 00000000..fda6686a
--- /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 00000000..a87c6fb9
--- /dev/null
+++ b/basilisk/services/template_service.py
@@ -0,0 +1,184 @@
+"""Mako-based template rendering service."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+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_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
+ ) -> 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/basilisk/views/base_conversation.py b/basilisk/views/base_conversation.py
index d18658a5..3dfb18d2 100644
--- a/basilisk/views/base_conversation.py
+++ b/basilisk/views/base_conversation.py
@@ -459,10 +459,13 @@ def apply_profile(
log.debug("no profile, select default account")
self.select_default_account()
return
- self.system_prompt_txt.SetValue(profile.system_prompt)
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)
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/conversation_profile_dialog.py b/basilisk/views/conversation_profile_dialog.py
index 8ed898b9..2c07bd16 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.
diff --git a/basilisk/views/conversation_tab.py b/basilisk/views/conversation_tab.py
index 66dfddf5..ef6cc582 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/html_view_window.py b/basilisk/views/html_view_window.py
index e62ba35a..3158c8f0 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
diff --git a/basilisk/views/main_frame.py b/basilisk/views/main_frame.py
index 25c0b545..858079e1 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.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.
@@ -494,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/basilisk/views/preferences_dialog.py b/basilisk/views/preferences_dialog.py
index 7b971550..b1ea1621 100644
--- a/basilisk/views/preferences_dialog.py
+++ b/basilisk/views/preferences_dialog.py
@@ -1,21 +1,122 @@
"""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,
RELEASE_CHANNELS,
PreferencesPresenter,
)
+from basilisk.views.view_mixins import ErrorDisplayMixin
log = logging.getLogger(__name__)
-class PreferencesDialog(wx.Dialog):
+class TemplatePathWidget(wx.Panel, ErrorDisplayMixin):
+ """A composite panel: label, path field, Browse and Write-default buttons."""
+
+ def __init__(
+ self,
+ parent: wx.Window,
+ label: str,
+ initial_path,
+ default_template_filename: str,
+ ):
+ """Create the widget panel.
+
+ Args:
+ parent: The parent window.
+ 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
+
+ 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(
+ self, value=str(initial_path) if initial_path else ""
+ )
+ row.Add(self._path_ctrl, 1, wx.EXPAND | wx.RIGHT, 4)
+
+ browse_btn = wx.Button(
+ self,
+ # 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(
+ self,
+ # 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)
+ self.SetSizer(sizer)
+
+ def _on_browse(self, event: wx.Event):
+ """Open a file dialog to select a template file."""
+ with wx.FileDialog(
+ self,
+ # 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,
+ # 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
+ )
+ 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:
+ """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, ErrorDisplayMixin):
"""A dialog to configure the application preferences."""
def __init__(
@@ -314,6 +415,35 @@ 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,
+ # 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,
+ # 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)
btn = wx.Button(panel, wx.ID_SAVE)
diff --git a/pyproject.toml b/pyproject.toml
index c3d88faa..d1b7b784 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/tests/presenters/test_base_conversation_presenter.py b/tests/presenters/test_base_conversation_presenter.py
index 5eb50c36..5cc200b2 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
@@ -197,3 +200,40 @@ def test_returns_none_none_when_no_account_or_model(self, mock_service):
account, model_id = p.resolve_account_and_model(profile)
assert account is None
assert model_id is None
+
+
+# ---------------------------------------------------------------------------
+# render_system_prompt()
+# ---------------------------------------------------------------------------
+
+
+class TestRenderSystemPrompt:
+ """Tests for BaseConversationPresenter.render_system_prompt."""
+
+ 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 == ""
+
+ 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_mako_variable_rendered(self, presenter):
+ """Mako ${now} variable is substituted."""
+ profile = MagicMock()
+ profile.system_prompt = "Date: ${now.year}"
+ result = presenter.render_system_prompt(profile, None, None)
+ assert str(datetime.now().year) in result
+
+ def test_invalid_template_falls_back(self, presenter):
+ """Invalid template logs warning and returns original string."""
+ profile = MagicMock()
+ profile.system_prompt = "${unclosed"
+ result = presenter.render_system_prompt(profile, None, None)
+ assert result == "${unclosed"
diff --git a/tests/presenters/test_conversation_export.py b/tests/presenters/test_conversation_export.py
new file mode 100644
index 00000000..bb1c87a3
--- /dev/null
+++ b/tests/presenters/test_conversation_export.py
@@ -0,0 +1,66 @@
+"""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="