Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ basilisk_config.yml
*.mo
user_data/
coverage.xml
.worktrees/
3 changes: 2 additions & 1 deletion basilisk/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -37,4 +37,5 @@
"KeyStorageMethodEnum",
"LogLevelEnum",
"ReleaseChannelEnum",
"TemplatesSettings",
]
9 changes: 9 additions & 0 deletions basilisk/config/main_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions basilisk/global_vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions basilisk/presenters/base_conversation_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions basilisk/presenters/conversation_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions basilisk/presenters/conversation_profile_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
20 changes: 0 additions & 20 deletions basilisk/presenters/main_frame_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
19 changes: 18 additions & 1 deletion basilisk/presenters/preferences_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import logging

from pydantic import ValidationError

import basilisk.config as config
from basilisk.config import (
AutomaticUpdateModeEnum,
Expand Down Expand Up @@ -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
Comment on lines +122 to +138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Roll back staged config changes when saving fails.

These assignments mutate the live config.conf() object before conf.save() runs. If save() raises, the dialog stays open but the process is still using the unsaved values, so a failed save can silently change runtime behavior for the rest of the session. Stage the edits on a copy or restore the previous config state in the except path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@basilisk/presenters/preferences_presenter.py` around lines 122 - 138, The
code mutates the live config (conf) before calling conf.save(), so on save
failure the runtime config is left changed; fix by staging edits on a copy or
restoring the previous state: capture the existing values from config.conf() (or
deep-copy the conf object) before assigning the new paths from
self.view.html_message_template_path.get_path() and
self.view.html_export_template_path.get_path(), then call conf.save() on the
modified copy (or apply assignments to the real conf only after successful
save); if save raises in the except block, restore the original values back into
the live config and then call self.view.show_error(...) so runtime state remains
unchanged on failure.

set_log_level(conf.general.log_level.name)

self.view.EndModal(wx.ID_OK)
73 changes: 73 additions & 0 deletions basilisk/res/templates/conversation_export.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${conversation.title or _("Conversation") | h}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; background: #fff; }
article { margin-bottom: 2rem; border-radius: 6px; padding: 1rem 1.25rem; }
article.user { background: #f0f4ff; }
article.assistant { background: #f6faf3; }
.meta { font-size: 0.8rem; color: #666; margin-top: 0.5rem; }
pre { background: #f4f4f4; padding: 0.75rem; border-radius: 4px; overflow-x: auto; }
code { font-family: monospace; }
@media (prefers-color-scheme: dark) {
body { background: #1a1a1a; color: #e8e8e8; }
article.user { background: #1e2a40; }
article.assistant { background: #1a2d1a; }
.meta { color: #aaa; }
pre { background: #2a2a2a; }
}
</style>
</head>
<body>
<header>
<h1>${conversation.title or _("Conversation") | h}</h1>
% if profile:
<p class="meta">${_("Profile")}: ${profile.name | h}</p>
% endif
</header>
<main role="log" aria-label="${_('Conversation history')}">
% for block in conversation.messages:
<article class="user" aria-label="${_('User message')}">
<div>${block.request.content | h}</div>
% 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:
<img src="data:${mime};base64,${b64}" alt="${att.name | h}" style="max-width:100%">
% else:
<a href="#" onclick="downloadAttachment('${b64}','${att.name | h}','${mime}');return false">${_("Download")} ${att.name | h}</a>
% endif
% endfor
% endif
</article>
% if block.response:
<article class="assistant" aria-label="${_('Assistant message')}">
<div>${block.response.content | h}</div>
<p class="meta">
${block.model.name | h} &mdash;
<time datetime="${block.created_at.isoformat()}">${block.created_at.strftime('%Y-%m-%d %H:%M')}</time>
</p>
</article>
% endif
% endfor
</main>
<script>
function downloadAttachment(b64, filename, mime) {
const bytes = atob(b64);
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
const blob = new Blob([arr], {type: mime});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
}
</script>
</body>
</html>
10 changes: 10 additions & 0 deletions basilisk/res/templates/html_message.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title | h}</title>
</head>
<body>
${content}
</body>
</html>
Loading