From 3b3502de8c4d34ec76d8c8fbe1114fad417fd432 Mon Sep 17 00:00:00 2001 From: Vivek Uppal Date: Thu, 30 May 2024 18:29:32 -0400 Subject: [PATCH] Edit Lines (#226) Add option to edit speaker text --- app/transcribe/appui.py | 85 +++++++++++++++++++----- app/transcribe/conversation.py | 56 +++++++++++++++- app/transcribe/db/conversation.py | 19 ++++++ app/transcribe/uicomp/selectable_text.py | 36 +++++++++- 4 files changed, 177 insertions(+), 19 deletions(-) diff --git a/app/transcribe/appui.py b/app/transcribe/appui.py index dead4a0..1aa00f0 100644 --- a/app/transcribe/appui.py +++ b/app/transcribe/appui.py @@ -184,15 +184,14 @@ def create_ui_components(self, config: dict): 'https://github.com/vivekuppal/transcribe/issues/new?referer=desktop')) # Create right click menu for transcript textbox. - # This displays only inside the speech to text textbox - m = tk.Menu(self.main_frame, tearoff=0) - m.add_command(label="Generate response for selected text", - command=self.get_response_selected_now) - m.add_command(label="Save Transcript to File", command=self.save_file) - m.add_command(label="Clear Audio Transcript", command=self.clear_transcript) - m.add_command(label="Copy Transcript to Clipboard", command=self.copy_to_clipboard) - m.add_separator() - m.add_command(label="Quit", command=self.quit) + self.transcript_text.add_right_click_menu(label="Generate response for selected text", + command=self.get_response_selected_now) + self.transcript_text.add_right_click_menu(label="Save Transcript to File", command=self.save_file) + self.transcript_text.add_right_click_menu(label="Clear Audio Transcript", command=self.clear_transcript) + self.transcript_text.add_right_click_menu(label="Copy Transcript to Clipboard", command=self.copy_to_clipboard) + self.transcript_text.add_right_click_menu(label="Edit line", command=self.edit_current_line) + self.transcript_text.add_right_menu_separator() + self.transcript_text.add_right_click_menu(label="Quit", command=self.quit) chat_inference_provider = config['General']['chat_inference_provider'] if chat_inference_provider == 'openai': @@ -226,14 +225,6 @@ def create_ui_components(self, config: dict): delay=0.01, follow=True, parent_kwargs={"padx": 3, "pady": 3}, padx=7, pady=7) - def show_context_menu(event): - try: - m.tk_popup(event.x_root, event.y_root) - finally: - m.grab_release() - - self.transcript_text.bind("", show_context_menu) - # self.grid_rowconfigure(0, weight=100) # self.grid_rowconfigure(1, weight=1) # self.grid_rowconfigure(2, weight=1) @@ -241,7 +232,67 @@ def show_context_menu(event): # self.grid_columnconfigure(0, weight=1) # self.grid_columnconfigure(1, weight=1) + + def edit_current_line(self): + """Edit the selected line of text required + """ + try: + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("dark-blue") + + current_line = self.transcript_text.text_widget.index("insert linestart") + current_line_text = self.transcript_text.text_widget.get(current_line, f"{current_line} lineend") + + edit_window = tk.Toplevel(self) + edit_window.title("Edit Line") + edit_window.configure(background='#252422') + + edit_text = tk.Text(edit_window, wrap="word", height=10, width=50, + bg='#252422', font=("Arial", 20), + foreground='#639cdc') + edit_text.pack(expand=True, fill='both') + # Separate Person, text in this line + end_speaker = current_line_text.find(':') + if end_speaker == -1: + # Could not determine speaker in text + return + speaker: str = current_line_text[:end_speaker].strip() + speaker_text: str = current_line_text[end_speaker+1:].strip() + if speaker_text[0] == '[': + speaker_text = speaker_text[1:] + if speaker_text[-1] == ']': + speaker_text = speaker_text[:-1] + edit_text.insert(tk.END, speaker_text) + + def save_edit(): + # Needs to do 3 things + # 1. Edit the text in SelectableText class + # 2. Edit the convo object + # 3. Save in DBs + new_text = edit_text.get("1.0", tk.END).strip() + self.transcript_text.text_widget.configure(state="normal") + self.transcript_text.text_widget.delete(current_line, f"{current_line} lineend") + self.transcript_text.text_widget.insert(current_line, f'{speaker}: {new_text}') + self.transcript_text.text_widget.configure(state="disabled") + # Separate persona, text + convo_id = self.global_vars.convo.get_convo_id(persona=speaker, input_text=speaker_text) + self.global_vars.convo.update_conversation_by_id(persona=speaker, convo_id=convo_id, text=new_text) + edit_window.destroy() + + def cancel_edit(): + edit_window.destroy() + save_button = ctk.CTkButton(edit_window, text="Save", command=save_edit) + save_button.pack(side=ctk.LEFT, padx=10, pady=10) + + cancel_button = ctk.CTkButton(edit_window, text="Cancel", command=cancel_edit) + cancel_button.pack(side=ctk.RIGHT, padx=10, pady=10) + + except tk.TclError: + pass # No text in the line + def create_menus(self): + """Create menus for the application + """ # Create the menu bar menubar = tk.Menu(self) diff --git a/app/transcribe/conversation.py b/app/transcribe/conversation.py index ff45b2c..3224f1b 100644 --- a/app/transcribe/conversation.py +++ b/app/transcribe/conversation.py @@ -12,7 +12,8 @@ class Conversation: """Encapsulates the complete conversation. - Has text from Speakers, Microphone, LLM, Instructions to LLM + The member transcript_data has separate lists for different personas. + Each list has a tuple of (ConversationText, time, conversation_id) """ _initialized: bool = False update_handler = None @@ -68,6 +69,34 @@ def clear_conversation_data(self): self.transcript_data[constants.PERSONA_ASSISTANT].clear() self.initialize_conversation() + def update_conversation_by_id(self, persona: str, convo_id: int, text: str): + """ + Update a conversation entry in the transcript_data list. + + Args: + persona (str): The persona whose conversation is to be updated. + convo_id (int): The ID of the conversation entry to update. + text (str): The new content of the conversation. + """ + transcript = self.transcript_data[persona] + + # Find the conversation with the given convo_id + for index, (_, time_spoken, current_convo_id) in enumerate(transcript): + if current_convo_id == convo_id: + # Update the conversation text + new_convo_text = f"{persona}: [{text}]\n\n" + transcript[index] = (new_convo_text, time_spoken, convo_id) + # Update the conversation in the database + if self._initialized: + # inv_id = appdb().get_invocation_id() + convo_object: convodb.Conversations = appdb().get_object(convodb.TABLE_NAME) + convo_object.update_conversation(convo_id, text) + # if persona.lower() != 'assistant': + # self.update_handler(persona, new_convo_text) + break + else: + print(f'Conversation with ID {convo_id} not found for persona {persona}.') + def update_conversation(self, persona: str, text: str, time_spoken, @@ -123,6 +152,31 @@ def update_conversation(self, persona: str, self.last_update = datetime.datetime.utcnow() + def get_convo_id(self, persona: str, input_text: str): + """ + Retrieves the ID of the conversation row that matches the given speaker and text. + + Args: + speaker (str): The name of the speaker. + text (str): The content of the conversation. + + Returns: + int: The ID of the matching conversation entry. + """ + if not self._initialized: + return + cleaned_text = input_text.strip() + if cleaned_text[0] == '[': + cleaned_text = cleaned_text[1:] + if cleaned_text[-1] == ']': + cleaned_text = cleaned_text[:-1] + inv_id = appdb().get_invocation_id() + convo_object: convodb.Conversations = appdb().get_object(convodb.TABLE_NAME) + convo_id = convo_object.get_convo_id_by_speaker_and_text(speaker=persona, + input_text=cleaned_text, + inv_id=inv_id) + return convo_id + def on_convo_select(self, input_text: str): """Callback when a specific conversation is selected. """ diff --git a/app/transcribe/db/conversation.py b/app/transcribe/db/conversation.py index 5e3049d..5648cea 100644 --- a/app/transcribe/db/conversation.py +++ b/app/transcribe/db/conversation.py @@ -147,6 +147,25 @@ def get_max_convo_id(self, speaker: str, inv_id: int) -> int: return convo_id + def get_convo_id_by_speaker_and_text(self, speaker: str, input_text: str, inv_id: int) -> int: + """ + Retrieves the ID of the conversation row that matches the given speaker and text. + + Args: + speaker (str): The name of the speaker. + text (str): The content of the conversation. + + Returns: + int: The ID of the matching conversation entry. + """ + stmt = text(f'SELECT Id FROM {self._table_name} WHERE Speaker = :speaker and Text = :text and InvocationId = :inv_id') + with Session(self.engine) as session: + result = session.execute(stmt, {'speaker': speaker, 'text': input_text, 'inv_id': inv_id}) + convo_id = result.scalar() + session.commit() + + return convo_id + def update_conversation(self, conversation_id: int, convo_text: str): """ Updates the text of a conversation entry in the Conversations table. diff --git a/app/transcribe/uicomp/selectable_text.py b/app/transcribe/uicomp/selectable_text.py index 847a55e..ef8468b 100644 --- a/app/transcribe/uicomp/selectable_text.py +++ b/app/transcribe/uicomp/selectable_text.py @@ -63,7 +63,7 @@ Get the text of the last 3 rows. """ -from tkinter import Text, Scrollbar, END +from tkinter import Text, Scrollbar, END, Menu, TclError import customtkinter as ctk @@ -71,6 +71,9 @@ class SelectableText(ctk.CTkFrame): """Custom TKinter Component to display multiple lines of text and support custom functionality on clicking a line of text. """ + + context_menu = None + def __init__(self, master=None, **kwargs): super().__init__(master, **kwargs) @@ -93,6 +96,37 @@ def __init__(self, master=None, **kwargs): self.text_widget.tag_configure("highlight", background="white") self.on_text_click_cb = None + # Bind right click menu + self.text_widget.bind("", self.show_context_menu) + + def add_right_click_menu(self, label, command): + """Add an entry to the right click menu. + """ + if not self.context_menu: + self.context_menu = Menu(self, tearoff=0) + self.context_menu.add_command(label=label, command=command) + + def get_selected_text(self): + self.text_widget.selection_get() + + def add_right_menu_separator(self): + """Add a separator to right click menu. + """ + self.context_menu.add_separator() + + def show_context_menu(self, event): + """Show the context menu. + """ + if self.context_menu: + self.context_menu.post(event.x_root, event.y_root) + + def copy_text(self): + try: + self.clipboard_clear() + self.clipboard_append(self.selection_get()) + except TclError: + pass # No text selected + def set_callbacks(self, onTextClick): """Set callback handlers """