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} + + + +
+

${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 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="", + ) + 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_main_frame_presenter.py b/tests/presenters/test_main_frame_presenter.py index a80197f2..f6ea0389 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.""" diff --git a/tests/presenters/test_preferences_presenter.py b/tests/presenters/test_preferences_presenter.py index 129402d9..c07a1835 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() diff --git a/tests/services/test_template_service.py b/tests/services/test_template_service.py new file mode 100644 index 00000000..0f19e79a --- /dev/null +++ b/tests/services/test_template_service.py @@ -0,0 +1,145 @@ +"""Tests for TemplateService.""" + +from __future__ import annotations + +from datetime import datetime +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 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.""" + 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.""" + + 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 "