Skip to content

Commit 447db3f

Browse files
AAClauseclementb49coderabbitai[bot]
authored
feat(accessible_output): add user feedback for various actions (#337)
* chore(deps): add accessible_output3 * feat(accessible_output): add initial user feedback for various actions * fix(pyproject): remove win32com and winerror from the exclude list * feat(accessible_output): add option for Accessible Output in conversation settings - Introduced a new configuration option `use_accessible_output` in the `ConversationSettings` class to enable or disable accessible output. - Implemented conditional checks in `conversation_tab.py` to use accessible output based on the new configuration setting. - Added a checkbox in the `PreferencesDialog` to allow users to enable or disable accessible output for spoken and braille feedback. * feat(accessible_output): Add feedback for response - Introduced `clear_for_speak` function to sanitize text by removing Markdown elements such as bold, italics, links, images, headers, blockquotes, and horizontal rules for improved accessible output. - Updated `accessible_output` initialization for clarity. - Enhanced `_flush_stream_buffer` and `_post_completion_without_stream` methods in `ConversationTab` to handle accessible text output using the new `clear_for_speak` function. - Modified `_handle_completion_with_stream` for better buffer handling with regular expression to match end of sentences. * Update basilisk/accessible_output.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor(accessible_output): replace init_accessible_output with cached get_accessible_output function * feat(accessible_output): speak on transcript reception * feat(conversation_tab): add 'Read Current Message' functionality * refactor(accessible_output): encapsulate accessible output handling and improve logging - Refactored `accessible_output` handling in `ConversationTab` by centralizing logic into a `_handle_accessible_output` method. - Enhanced initialization logging in `accessible_output.py` by adding a logger. - Removed redundant focus checks before handling accessible output. - Improved accessibility support by adding optional Braille output capability. - Simplified conditions for message focusing based on accessibility settings. * feat(accessible_output): add toggle for 'speak stream' - Introduced `_speak_stream` attribute to control whether stream output is spoken. - Added `on_toggle_speak_stream` method to toggle the speak stream state. - Assigned Shift+Space as the keyboard shortcut to toggle speak stream. - Included "Speak stream" as an option in the context menu, reflecting the current state. - Implemented error handling for braille and speech output to prevent application crash. - Adjusted focus handling when processing message streams. * fix(accessible_output): remove extraneous periods in accessible output messages Removed unnecessary periods from the accessible output messages for better consistency. Added braille output option to the "Message copied to clipboard" message. * fix: include package in zip --------- Co-authored-by: clementb49 <clement.boussiron@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent c308ca4 commit 447db3f

7 files changed

Lines changed: 425 additions & 36 deletions

File tree

basilisk/accessible_output.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import logging
2+
import re
3+
from functools import cache
4+
5+
import accessible_output3.outputs.auto
6+
7+
log = logging.getLogger(__name__)
8+
9+
10+
@cache
11+
def get_accessible_output():
12+
log.info("Initializing Accessible Output")
13+
return accessible_output3.outputs.auto.Auto()
14+
15+
16+
def clear_for_speak(text: str) -> str:
17+
"""Remove common Markdown elements from text for accessible output."""
18+
# Remove bold and italics
19+
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
20+
text = re.sub(r"__(.*?)__", r"\1", text)
21+
text = re.sub(r"\*(.*?)\*", r"\1", text)
22+
text = re.sub(r"_(.*?)_", r"\1", text)
23+
24+
# Remove links but keep the link text
25+
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
26+
27+
# Remove images (keep alt text)
28+
text = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", r"\1", text)
29+
30+
# Remove headers
31+
text = re.sub(r"^#{1,6} (.*)", r"\1", text, flags=re.MULTILINE)
32+
33+
# Remove blockquotes
34+
text = re.sub(r"^> (.*)", r"\1", text, flags=re.MULTILINE)
35+
36+
# Remove horizontal rules
37+
text = re.sub(r"^-{3,}$", r"", text, flags=re.MULTILINE)
38+
39+
return text

basilisk/config/main_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ConversationSettings(BaseModel):
3737
role_label_assistant: str | None = Field(default=None)
3838
nav_msg_select: bool = Field(default=False)
3939
shift_enter_mode: bool = Field(default=False)
40+
use_accessible_output: bool = Field(default=True)
4041

4142

4243
class ImagesSettings(BaseModel):

basilisk/gui/conversation_tab.py

Lines changed: 116 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import basilisk.config as config
1616
from basilisk import global_vars
17+
from basilisk.accessible_output import clear_for_speak, get_accessible_output
1718
from basilisk.conversation import (
1819
PROMPT_TITLE,
1920
Conversation,
@@ -43,6 +44,7 @@
4344
from .main_frame import MainFrame
4445

4546
log = logging.getLogger(__name__)
47+
accessible_output = get_accessible_output()
4648

4749

4850
def ensure_no_task_running(method: Callable):
@@ -85,7 +87,7 @@ def __init__(
8587
self.recording_thread: Optional[RecordingThread] = None
8688
self.task = None
8789
self.stream_buffer = ""
88-
self._messages_already_focused = False
90+
self._speak_stream = True
8991
self._stop_completion = False
9092
self._search_dialog = None
9193
self.init_ui()
@@ -540,6 +542,10 @@ def navigate_message(self, previous: bool):
540542
self.messages.SetInsertionPoint(pos)
541543
if config.conf().conversation.nav_msg_select:
542544
self.select_current_message()
545+
else:
546+
start, end = self.get_range_for_current_message()
547+
current_message = self.messages.GetRange(start, end)
548+
self._handle_accessible_output(current_message)
543549

544550
def go_to_previous_message(self, event: wx.CommandEvent = None):
545551
self.navigate_message(True)
@@ -552,24 +558,51 @@ def move_to_start_of_message(self, event: wx.CommandEvent = None):
552558
self.message_segment_manager.absolute_position = cursor_pos
553559
self.message_segment_manager.focus_content_block()
554560
self.messages.SetInsertionPoint(self.message_segment_manager.start)
561+
self._handle_accessible_output(_("Start of message"))
555562

556563
def move_to_end_of_message(self, event: wx.CommandEvent = None):
557564
cursor_pos = self.messages.GetInsertionPoint()
558565
self.message_segment_manager.absolute_position = cursor_pos
559566
self.message_segment_manager.focus_content_block()
560567
self.messages.SetInsertionPoint(self.message_segment_manager.end - 1)
568+
self._handle_accessible_output(_("End of message"))
561569

562-
def select_current_message(self):
570+
def get_range_for_current_message(self) -> tuple[int, int]:
563571
cursor_pos = self.messages.GetInsertionPoint()
564572
self.message_segment_manager.absolute_position = cursor_pos
565573
self.message_segment_manager.focus_content_block()
566574
start = self.message_segment_manager.start
567575
end = self.message_segment_manager.end
576+
return start, end
577+
578+
def select_current_message(self):
579+
start, end = self.get_range_for_current_message()
568580
self.messages.SetSelection(start, end)
569581

570582
def on_select_message(self, event: wx.CommandEvent = None):
571583
self.select_current_message()
572584

585+
def on_toggle_speak_stream(self, event: wx.CommandEvent = None):
586+
if event:
587+
return wx.CallLater(500, self.on_toggle_speak_stream)
588+
self._speak_stream = not self._speak_stream
589+
self._handle_accessible_output(
590+
_("Stream speaking %s")
591+
% (_("enabled") if self._speak_stream else _("disabled")),
592+
braille=True,
593+
)
594+
595+
def on_read_current_message(self, event: wx.CommandEvent = None):
596+
if event:
597+
return wx.CallLater(500, self.on_read_current_message)
598+
cursor_pos = self.messages.GetInsertionPoint()
599+
self.message_segment_manager.absolute_position = cursor_pos
600+
self.message_segment_manager.focus_content_block()
601+
start = self.message_segment_manager.start
602+
end = self.message_segment_manager.end
603+
content = self.messages.GetRange(start, end)
604+
self._handle_accessible_output(content, force=True)
605+
573606
def on_show_as_html(self, event: wx.CommandEvent = None):
574607
cursor_pos = self.messages.GetInsertionPoint()
575608
self.message_segment_manager.absolute_position = cursor_pos
@@ -583,6 +616,9 @@ def on_copy_message(self, event: wx.CommandEvent = None):
583616
self.select_current_message()
584617
self.messages.Copy()
585618
self.messages.SetInsertionPoint(cursor_pos)
619+
self._handle_accessible_output(
620+
_("Message copied to clipboard"), braille=True
621+
)
586622

587623
def on_remove_message_block(self, event: wx.CommandEvent = None):
588624
cursor_pos = self.messages.GetInsertionPoint()
@@ -594,6 +630,10 @@ def on_remove_message_block(self, event: wx.CommandEvent = None):
594630
self.conversation.messages.remove(message_block)
595631
self.refresh_messages()
596632
self.messages.SetInsertionPoint(cursor_pos)
633+
self._handle_accessible_output(
634+
_("Message block removed"), braille=True
635+
)
636+
597637
else:
598638
wx.Bell()
599639

@@ -605,6 +645,8 @@ def on_messages_key_down(self, event: wx.KeyEvent = None):
605645
key_code = event.GetKeyCode()
606646

607647
key_actions = {
648+
(wx.MOD_SHIFT, wx.WXK_SPACE): self.on_toggle_speak_stream,
649+
(wx.MOD_NONE, wx.WXK_SPACE): self.on_read_current_message,
608650
(wx.MOD_NONE, ord('J')): self.go_to_previous_message,
609651
(wx.MOD_NONE, ord('K')): self.go_to_next_message,
610652
(wx.MOD_NONE, ord('S')): self.on_select_message,
@@ -634,23 +676,22 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
634676
menu,
635677
wx.ID_ANY,
636678
# Translators: This is a label for the Messages area context menu in the main window
637-
_("Find...") + " (&f)",
679+
_("Read current message") + " (space)",
638680
)
639681
menu.Append(item)
640-
self.Bind(wx.EVT_MENU, self.on_search_in_messages, item)
682+
self.Bind(wx.EVT_MENU, self.on_read_current_message, item)
683+
641684
item = wx.MenuItem(
642685
menu,
643686
wx.ID_ANY,
644687
# Translators: This is a label for the Messages area context menu in the main window
645-
_("Find Next") + " (F3)",
646-
)
647-
menu.Append(item)
648-
self.Bind(wx.EVT_MENU, self.on_search_in_messages_next, item)
649-
item = wx.MenuItem(
650-
menu, wx.ID_ANY, _("Find Previous") + " (Shift+F3)"
688+
_("Speak stream") + " (Shift+Space)",
689+
_("Speak stream"),
690+
wx.ITEM_CHECK,
651691
)
652692
menu.Append(item)
653-
self.Bind(wx.EVT_MENU, self.on_search_in_messages_previous, item)
693+
item.Check(self._speak_stream)
694+
self.Bind(wx.EVT_MENU, self.on_toggle_speak_stream, item)
654695

655696
item = wx.MenuItem(
656697
menu,
@@ -669,6 +710,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
669710
)
670711
menu.Append(item)
671712
self.Bind(wx.EVT_MENU, self.on_copy_message, item)
713+
672714
item = wx.MenuItem(
673715
menu,
674716
wx.ID_ANY,
@@ -677,6 +719,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
677719
)
678720
menu.Append(item)
679721
self.Bind(wx.EVT_MENU, self.on_select_message, item)
722+
680723
item = wx.MenuItem(
681724
menu,
682725
wx.ID_ANY,
@@ -685,6 +728,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
685728
)
686729
menu.Append(item)
687730
self.Bind(wx.EVT_MENU, self.go_to_previous_message, item)
731+
688732
item = wx.MenuItem(
689733
menu,
690734
wx.ID_ANY,
@@ -693,6 +737,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
693737
)
694738
menu.Append(item)
695739
self.Bind(wx.EVT_MENU, self.go_to_next_message, item)
740+
696741
item = wx.MenuItem(
697742
menu,
698743
wx.ID_ANY,
@@ -701,6 +746,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
701746
)
702747
menu.Append(item)
703748
self.Bind(wx.EVT_MENU, self.move_to_start_of_message, item)
749+
704750
item = wx.MenuItem(
705751
menu,
706752
wx.ID_ANY,
@@ -709,6 +755,7 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
709755
)
710756
menu.Append(item)
711757
self.Bind(wx.EVT_MENU, self.move_to_end_of_message, item)
758+
712759
item = wx.MenuItem(
713760
menu,
714761
wx.ID_ANY,
@@ -717,6 +764,31 @@ def on_messages_context_menu(self, event: wx.ContextMenuEvent):
717764
)
718765
menu.Append(item)
719766
self.Bind(wx.EVT_MENU, self.on_remove_message_block, item)
767+
768+
item = wx.MenuItem(
769+
menu,
770+
wx.ID_ANY,
771+
# Translators: This is a label for the Messages area context menu in the main window
772+
_("Find...") + " (&f)",
773+
)
774+
menu.Append(item)
775+
self.Bind(wx.EVT_MENU, self.on_search_in_messages, item)
776+
777+
item = wx.MenuItem(
778+
menu,
779+
wx.ID_ANY,
780+
# Translators: This is a label for the Messages area context menu in the main window
781+
_("Find Next") + " (F3)",
782+
)
783+
menu.Append(item)
784+
self.Bind(wx.EVT_MENU, self.on_search_in_messages_next, item)
785+
786+
item = wx.MenuItem(
787+
menu, wx.ID_ANY, _("Find Previous") + " (Shift+F3)"
788+
)
789+
menu.Append(item)
790+
self.Bind(wx.EVT_MENU, self.on_search_in_messages_previous, item)
791+
720792
self.add_standard_context_menu_items(menu)
721793
self.messages.PopupMenu(menu)
722794
menu.Destroy()
@@ -947,6 +1019,8 @@ def on_transcription_received(self, transcription):
9471019
stop_sound()
9481020
self.SetStatusText(_("Ready"))
9491021
self.prompt.AppendText(transcription.text)
1022+
if self.prompt.HasFocus() and self.GetTopLevelParent().IsShown():
1023+
self._handle_accessible_output(transcription.text)
9501024
self.prompt.SetInsertionPointEnd()
9511025
self.prompt.SetFocus()
9521026

@@ -1036,6 +1110,7 @@ def on_submit(self, event: wx.CommandEvent):
10361110
"new_block": new_block,
10371111
"stream": new_block.stream,
10381112
}
1113+
self.messages.SetFocus()
10391114
thread = self.task = threading.Thread(
10401115
target=self._handle_completion, kwargs=completion_kw
10411116
)
@@ -1092,19 +1167,42 @@ def _pre_handle_completion_with_stream(self, new_block: MessageBlock):
10921167

10931168
def _handle_completion_with_stream(self, chunk: str):
10941169
self.stream_buffer += chunk
1095-
if '\n' in chunk or len(self.stream_buffer) > 120:
1170+
if re.match(r".+[\n:.?!]\s?$", self.stream_buffer):
10961171
self._flush_stream_buffer()
1097-
if not self._messages_already_focused:
1098-
self.messages.SetFocus()
1099-
self._messages_already_focused = True
11001172
new_time = time.time()
11011173
if new_time - self.last_time > 4:
11021174
play_sound("chat_response_pending")
11031175
self.last_time = new_time
11041176

1177+
def _handle_accessible_output(
1178+
self, text: str, braille: bool = False, force: bool = False
1179+
):
1180+
if (
1181+
(not force and not config.conf().conversation.use_accessible_output)
1182+
or not isinstance(text, str)
1183+
or not text.strip()
1184+
):
1185+
return
1186+
if braille:
1187+
try:
1188+
accessible_output.braille(text)
1189+
except Exception:
1190+
log.error("Error during braille output", exc_info=True)
1191+
try:
1192+
accessible_output.speak(clear_for_speak(text))
1193+
except Exception:
1194+
log.error("Error during speech output", exc_info=True)
1195+
11051196
def _flush_stream_buffer(self):
11061197
pos = self.messages.GetInsertionPoint()
1107-
self.messages.AppendText(self.stream_buffer)
1198+
text = self.stream_buffer
1199+
if (
1200+
self._speak_stream
1201+
and self.messages.HasFocus()
1202+
and self.GetTopLevelParent().IsShown()
1203+
):
1204+
self._handle_accessible_output(text)
1205+
self.messages.AppendText(text)
11081206
self.stream_buffer = ""
11091207
self.messages.SetInsertionPoint(pos)
11101208

@@ -1118,20 +1216,17 @@ def _post_completion_with_stream(self, new_block: MessageBlock):
11181216
self._flush_stream_buffer()
11191217
self._update_last_segment_length()
11201218
self._end_task()
1121-
self._messages_already_focused = False
11221219

11231220
def _post_completion_without_stream(self, new_block: MessageBlock):
1124-
self._end_task()
11251221
self.conversation.messages.append(new_block)
11261222
self.display_new_block(new_block)
1223+
self._handle_accessible_output(new_block.response.content)
11271224
self.prompt.Clear()
11281225
self.image_files.clear()
11291226
self.refresh_images_list()
1227+
self._end_task()
11301228

11311229
def _end_task(self, success: bool = True):
1132-
if not self._messages_already_focused:
1133-
self.messages.SetFocus()
1134-
self._messages_already_focused = True
11351230
task = self.task
11361231
task.join()
11371232
thread_id = task.ident

basilisk/gui/preferences_dialog.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ def init_ui(self):
196196
self.shift_enter_mode.SetValue(self.conf.conversation.shift_enter_mode)
197197
conversation_group_sizer.Add(self.shift_enter_mode, 0, wx.ALL, 5)
198198

199+
self.use_accessible_output = wx.CheckBox(
200+
conversation_group,
201+
# Translators: A label for a checkbox in the preferences dialog
202+
label=_(
203+
"Enable &Accessible Output to provide spoken and braille feedback for actions and messages"
204+
),
205+
)
206+
self.use_accessible_output.SetValue(
207+
self.conf.conversation.use_accessible_output
208+
)
209+
conversation_group_sizer.Add(self.use_accessible_output, 0, wx.ALL, 5)
210+
199211
sizer.Add(conversation_group_sizer, 0, wx.ALL, 5)
200212

201213
images_group = wx.StaticBox(panel, label=_("Images"))
@@ -344,6 +356,9 @@ def on_ok(self, event):
344356
self.conf.conversation.shift_enter_mode = (
345357
self.shift_enter_mode.GetValue()
346358
)
359+
self.conf.conversation.use_accessible_output = (
360+
self.use_accessible_output.GetValue()
361+
)
347362

348363
self.conf.images.resize = self.image_resize.GetValue()
349364
self.conf.images.max_height = int(self.image_max_height.GetValue())

0 commit comments

Comments
 (0)