diff --git a/codeaide/logic/chat_handler.py b/codeaide/logic/chat_handler.py index 495900d..bcc63f2 100644 --- a/codeaide/logic/chat_handler.py +++ b/codeaide/logic/chat_handler.py @@ -18,10 +18,11 @@ from codeaide.utils.cost_tracker import CostTracker from codeaide.utils.file_handler import FileHandler from codeaide.utils.terminal_manager import TerminalManager -from codeaide.utils.general_utils import generate_session_id -from codeaide.utils.logging_config import get_logger, setup_logger +from codeaide.utils.general_utils import get_project_root +from codeaide.utils.logging_config import get_logger from PyQt5.QtCore import QObject, pyqtSignal from codeaide.utils.environment_manager import EnvironmentManager +from codeaide.utils.session_manager import SessionManager class ChatHandler(QObject): @@ -31,31 +32,21 @@ class ChatHandler(QObject): ) # Signal to update chat with (role, message) show_code_signal = pyqtSignal(str, str) # Signal to show code with (code, version) traceback_occurred = pyqtSignal(str) + session_updated = pyqtSignal() def __init__(self): super().__init__() - """ - Initialize the ChatHandler class. + base_dir = get_project_root() + self.file_handler = FileHandler(base_dir=base_dir) + self.session_manager = SessionManager(base_dir, self.file_handler) - Args: - None + self.session_id = None + self.session_dir = None - Returns: - None - """ - self.session_id = generate_session_id() self.cost_tracker = CostTracker() - self.file_handler = FileHandler(session_id=self.session_id) - self.session_dir = ( - self.file_handler.session_dir - ) # Store the specific session directory self.logger = get_logger() - self.conversation_history = self.file_handler.load_chat_history() - self.environment_manager = EnvironmentManager(self.session_id) - self.terminal_manager = TerminalManager( - environment_manager=self.environment_manager, - traceback_callback=self.emit_traceback_signal, - ) + self.environment_manager = None + self.terminal_manager = None self.latest_version = "0.0" self.api_client = None self.api_key_set = False @@ -66,17 +57,26 @@ def __init__(self): self.max_tokens = AI_PROVIDERS[self.current_provider]["models"][ self.current_model ]["max_tokens"] - self.env_manager = EnvironmentManager(self.session_id) self.api_key_valid, self.api_key_message = self.check_api_key() - self.logger.info(f"New session started with ID: {self.session_id}") - self.logger.info(f"Session directory: {self.session_dir}") self.chat_window = None def start_application(self): - from codeaide.ui.chat_window import ( - ChatWindow, - ) # Import here to avoid circular imports + from codeaide.ui.chat_window import ChatWindow + + # Create initial session + self.session_id = self.session_manager.create_new_session() + self.session_dir = self.file_handler.session_dir + + self.conversation_history = self.file_handler.load_chat_history() + self.environment_manager = EnvironmentManager(self.session_id) + self.terminal_manager = TerminalManager( + environment_manager=self.environment_manager, + traceback_callback=self.emit_traceback_signal, + ) + + self.logger.info(f"New session started with ID: {self.session_id}") + self.logger.info(f"Session directory: {self.session_dir}") self.chat_window = ChatWindow(self) self.connect_signals() @@ -333,8 +333,13 @@ def process_ai_response(self, response): code_version, version_description, requirements, + session_summary, ) = parsed_response + # Update the session summary + if session_summary: + self.update_session_summary(session_summary) + if code and self.compare_versions(code_version, self.latest_version) <= 0: raise ValueError( f"New version {code_version} is not higher than the latest version {self.latest_version}" @@ -567,55 +572,70 @@ def get_latest_version(self): def set_latest_version(self, version): self.latest_version = version - def start_new_session(self, chat_window): - self.logger.info("Starting new session") - - # Log the previous session path correctly - self.logger.info(f"Previous session path: {self.session_dir}") - - # Generate new session ID - new_session_id = generate_session_id() - - # Create new FileHandler with new session ID - new_file_handler = FileHandler(session_id=new_session_id) - - # Copy existing log to new session and set up new logger - self.file_handler.copy_log_to_new_session(new_session_id) - setup_logger(new_file_handler.session_dir) - - # Update instance variables + def start_new_session(self, chat_window, based_on=None, initial_session=False): + new_session_id = self.session_manager.create_new_session(based_on=based_on) self.session_id = new_session_id - self.file_handler = new_file_handler - self.session_dir = new_file_handler.session_dir # Update the session directory + self.file_handler.set_session_id(new_session_id) + self.session_dir = self.file_handler.session_dir - # Clear conversation history self.conversation_history = [] - - # Clear chat display in UI chat_window.clear_chat_display() - - # Close code pop-up if it exists chat_window.close_code_popup() - # Add system message about previous session - system_message = f"A new session has been started. The previous chat will not be visible to the agent. Previous session data saved in: {self.session_dir}" - chat_window.add_to_chat("System", system_message) - chat_window.add_to_chat("AI", INITIAL_MESSAGE) + if not initial_session: + # Inform the user that a new session is starting if this is done after an existing session was underway + system_message = f"A new session has been started. The previous chat will not be visible to the agent. Previous session data saved in: {self.session_dir}" + chat_window.add_to_chat("System", system_message) + chat_window.add_to_chat("AI", INITIAL_MESSAGE) self.logger.info(f"New session started with ID: {self.session_id}") self.logger.info(f"New session directory: {self.session_dir}") - # New method to load a previous session + return new_session_id + def load_previous_session(self, session_id, chat_window): + if session_id is None: + self.logger.error("Attempted to load a session with None id") + raise ValueError("Invalid session id: None") + self.logger.info(f"Loading previous session: {session_id}") - self.session_id = session_id - self.file_handler = FileHandler(session_id=session_id) + new_session_id = self.session_manager.load_previous_session(session_id) + self.session_id = new_session_id + self.file_handler.set_session_id(new_session_id) self.session_dir = self.file_handler.session_dir - # Load chat contents - chat_window.load_chat_contents() + # Load chat history from the original session + self.conversation_history = self.file_handler.load_chat_history(session_id) + + chat_window.clear_chat_display() + # Load chat contents from the original session + chat_contents = self.file_handler.load_chat_contents(session_id) + for content in chat_contents: + if isinstance(content, dict) and "role" in content and "content" in content: + chat_window.add_to_chat(content["role"], content["content"]) + elif ( + isinstance(content, dict) + and "sender" in content + and "message" in content + ): + chat_window.add_to_chat(content["sender"], content["message"]) + else: + self.logger.warning(f"Unexpected chat content format: {content}") + + system_message = f"A new session has been created based on session {session_id}. Previous session data copied to: {self.session_dir}" + chat_window.add_to_chat("System", system_message) + + self.logger.info(f"Loaded previous session with ID: {session_id}") + self.logger.info(f"Created new session with ID: {new_session_id}") + + return new_session_id + + def update_session_summary(self, summary): + self.session_manager.update_session_summary(summary) + self.session_updated.emit() - self.logger.info(f"Loaded previous session with ID: {self.session_id}") + def get_all_sessions(self): + return self.session_manager.get_all_sessions() def emit_traceback_signal(self, traceback_text): self.logger.info( @@ -638,4 +658,4 @@ def send_traceback_to_agent(self, traceback_text): self.chat_window.on_submit() def cleanup(self): - self.env_manager.cleanup() + self.environment_manager.cleanup() diff --git a/codeaide/ui/chat_window.py b/codeaide/ui/chat_window.py index 6af37a1..14dfed3 100644 --- a/codeaide/ui/chat_window.py +++ b/codeaide/ui/chat_window.py @@ -167,6 +167,7 @@ def __init__(self, chat_handler): self.timer.timeout.connect(lambda: None) self.logger.info("Chat window initialized") + self.chat_handler.session_updated.connect(self.update_session_dropdown) def setup_ui(self): central_widget = QWidget(self) @@ -175,13 +176,13 @@ def setup_ui(self): main_layout.setSpacing(5) main_layout.setContentsMargins(8, 8, 8, 8) - # Create a widget for the dropdowns + # Create a widget for the dropdowns (keep this as is) dropdown_widget = QWidget() dropdown_layout = QHBoxLayout(dropdown_widget) dropdown_layout.setContentsMargins(0, 0, 0, 0) dropdown_layout.setSpacing(5) - # Provider dropdown + # Provider dropdown (keep this as is) self.provider_dropdown = QComboBox() self.provider_dropdown.addItems(AI_PROVIDERS.keys()) self.provider_dropdown.setCurrentText(DEFAULT_PROVIDER) @@ -189,19 +190,37 @@ def setup_ui(self): dropdown_layout.addWidget(QLabel("Provider:")) dropdown_layout.addWidget(self.provider_dropdown) - # Model dropdown + # Model dropdown (keep this as is) self.model_dropdown = QComboBox() self.update_model_dropdown(DEFAULT_PROVIDER, add_message_to_chat=False) self.model_dropdown.currentTextChanged.connect(self.update_chat_handler) dropdown_layout.addWidget(QLabel("Model:")) dropdown_layout.addWidget(self.model_dropdown) - # Add stretch to push everything to the left + # Add stretch to push everything to the left (keep this as is) dropdown_layout.addStretch(1) - # Add the dropdown widget to the main layout + # Add the dropdown widget to the main layout (keep this as is) main_layout.addWidget(dropdown_widget) + # Create a new widget for the session selector + session_widget = QWidget() + session_layout = QHBoxLayout(session_widget) + session_layout.setContentsMargins(0, 0, 0, 0) + session_layout.setSpacing(5) + + # Session selector dropdown + self.session_dropdown = QComboBox() + session_layout.addWidget(QLabel("Session Selector:")) + session_layout.addWidget(self.session_dropdown) + session_layout.addStretch(1) # Add stretch to keep alignment consistent + + # Add the session widget to the main layout + main_layout.addWidget(session_widget) + + # Now that self.session_dropdown is initialized, we can update it + self.update_session_dropdown() + # Chat display self.chat_display = QTextEdit(self) self.chat_display.setReadOnly(True) @@ -532,7 +551,7 @@ def on_new_session_clicked(self): if reply == QMessageBox.Yes: self.logger.info("User confirmed starting a new session") - self.chat_handler.start_new_session(self) + self.chat_handler.start_new_session(self, initial_session=False) else: self.logger.info("User cancelled starting a new session") @@ -750,3 +769,85 @@ def scroll_to_bottom(self): # Scroll to the bottom scrollbar = self.input_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) + + def update_session_dropdown(self): + self.session_dropdown.blockSignals(True) + self.session_dropdown.clear() + sessions = self.chat_handler.get_all_sessions() + current_session_id = self.chat_handler.session_id + + # Always add the current session at the top + current_session = next( + (s for s in sessions if s["id"] == current_session_id), None + ) + if current_session: + self.session_dropdown.addItem( + f"{current_session_id} (Current) - {current_session['summary']}", + current_session_id, + ) + else: + # If the current session is not in the list (e.g., it's new and empty), add it manually + current_summary = ( + self.chat_handler.session_manager.get_session_summary( + current_session_id + ) + or "New session" + ) + self.session_dropdown.addItem( + f"{current_session_id} (Current) - {current_summary}", + current_session_id, + ) + + # Add other non-empty sessions + for session in sessions: + if session["id"] != current_session_id: + self.session_dropdown.addItem( + f"{session['id']} - {session['summary']}", session["id"] + ) + + self.session_dropdown.setCurrentIndex(0) + self.session_dropdown.blockSignals(False) + + # Reconnect the signal + self.session_dropdown.currentIndexChanged.connect(self.on_session_selected) + + def on_session_selected(self, index): + if index == 0: # Current session + return + + selected_session_id = self.session_dropdown.itemData(index) + if selected_session_id is None: + self.logger.error(f"Invalid session selected at index {index}") + self.session_dropdown.setCurrentIndex(0) + return + + reply = QMessageBox.question( + self, + "Load Previous Session", + f"This will create a new session based on session {selected_session_id}. Are you sure you'd like to proceed?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply == QMessageBox.Yes: + self.logger.info( + f"User confirmed loading previous session: {selected_session_id}" + ) + try: + self.update_session_dropdown() + # Disconnect and reconnect the signal to prevent multiple calls + self.session_dropdown.currentIndexChanged.disconnect() + self.session_dropdown.setCurrentIndex(0) + self.session_dropdown.currentIndexChanged.connect( + self.on_session_selected + ) + except Exception as e: + self.logger.error(f"Error loading previous session: {str(e)}") + QMessageBox.critical( + self, "Error", f"Failed to load previous session: {str(e)}" + ) + self.session_dropdown.setCurrentIndex(0) + else: + self.logger.info("User cancelled loading previous session") + # Reset the dropdown to the current session + self.session_dropdown.setCurrentIndex(0) diff --git a/codeaide/utils/api_utils.py b/codeaide/utils/api_utils.py index 2a2695c..d3bd6a8 100644 --- a/codeaide/utils/api_utils.py +++ b/codeaide/utils/api_utils.py @@ -165,12 +165,21 @@ def parse_response(response, provider): version_description = outer_json.get("version_description") requirements = outer_json.get("requirements", []) questions = outer_json.get("questions", []) + session_summary = outer_json.get("session_summary", "") # Clean the code if it exists if code: code = clean_code(code) - return text, questions, code, code_version, version_description, requirements + return ( + text, + questions, + code, + code_version, + version_description, + requirements, + session_summary, + ) def clean_code(code): diff --git a/codeaide/utils/constants.py b/codeaide/utils/constants.py index 21aa188..a12782c 100644 --- a/codeaide/utils/constants.py +++ b/codeaide/utils/constants.py @@ -75,6 +75,7 @@ # System prompt for API requests SYSTEM_PROMPT = """ You are an AI assistant specialized in providing coding advice and solutions. Your primary goal is to offer practical, working code examples while balancing the need for clarification with the ability to make reasonable assumptions. Follow these guidelines: + * Prioritize providing functional code: When asked for code solutions, aim to deliver complete, runnable Python code whenever possible. * Always return complete, fully functional code. Never use ellipses (...) or comments like "other methods remain unchanged" to indicate omitted parts. Every method, function, and class must be fully implemented in each response. * Never assume that a necessary support file exists unless explicitly stated in the user's query. If you need to create additional files (such as .wav files for game sound effects), include code that generates and saves them to the appropriate location within the response. @@ -103,6 +104,10 @@ * Double check that all required modules are included in the 'requirements' field. If the user tells you they say a ModuleNotFoundError, double check this field to ensure that all required modules are present. * If the user reports a problem with the code you provided, apologize and try to fix it. Explain what you changed and why that should fix the problem. +Additionally, for each response: +* Provide a brief session summary (5-10 words) that encapsulates the main topic or goal of the current conversation. This summary should be concise yet informative, allowing users to quickly understand the context of the session. +* Update this summary with each response to reflect the evolving nature of the conversation. + Code Formatting Guidelines: * When writing code that includes string literals with newlines, use appropriate multi-line string formatting for the language. For example, in Python: - Use triple quotes for multi-line strings. @@ -116,12 +121,13 @@ * Do not include triple backticks ("```") or language identifiers in the code block. Remember, the goal is to provide valuable, working code solutions while maintaining a balance between making reasonable assumptions and seeking clarification when truly necessary. -Format your responses as a JSON object with six keys: +Format your responses as a JSON object with seven keys: * 'text': a string that contains any natural language explanations or comments that you think are helpful for the user. This should never be null or incomplete. If you mention providing a list or explanation, ensure it is fully included here. If you have no text response, provide a brief explanation of the code or the assumptions made. Use plain text, not markdown. * 'questions': an array of strings that pose necessary follow-up questions to the user * 'code': a string with the properly formatted, complete code block. This must include all necessary components for the code to run, including any previously implemented methods or classes. This should be null only if you have questions or text responses but no code to provide. * 'code_version': a string that represents the version of the code. Start at 1.0 and increment for each new version of the code you provide. Use your judgement on whether to increment the minor or major component of the version. It is critical that version numbers never be reused during a chat and that the numbers always increment upward. This field should be null if you have no code to provide. * 'version_description': a very short string that describes the purpose of the code and/or changes made in this version of the code since the last version. This should be null if you have questions or text responses but no code to provide. * 'requirements': an array of strings listing any required Python packages or modules that are necessary to run the code. This should be null if no additional requirements are needed beyond the standard Python libraries. +* 'session_summary': a string containing a brief (5-10 words) summary of the current session's main topic(s) or goal(s). This should be updated with each response to reflect the current state of the conversation. Do not include any text outside of the JSON object. """ diff --git a/codeaide/utils/file_handler.py b/codeaide/utils/file_handler.py index 1925bc5..2eb0c86 100644 --- a/codeaide/utils/file_handler.py +++ b/codeaide/utils/file_handler.py @@ -2,14 +2,13 @@ import shutil import json from codeaide.utils.logging_config import setup_logger, get_logger +from codeaide.utils.general_utils import get_project_root class FileHandler: def __init__(self, base_dir=None, session_id=None): if base_dir is None: - self.base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ) + self.base_dir = get_project_root else: self.base_dir = base_dir self.output_dir = os.path.join(self.base_dir, "session_data") @@ -110,15 +109,31 @@ def save_chat_history(self, conversation_history): except Exception as e: self.logger.error(f"Error saving chat history: {str(e)}") - def load_chat_history(self): - if not self.session_dir or not os.path.exists(self.chat_history_file): + def load_chat_history(self, session_id=None): + """ + Load the chat history for a given session. + If no session_id is provided, it loads the current session's history. + """ + if session_id is None: + session_id = self.session_id + + if not session_id: + return [] + + chat_history_file = os.path.join( + self.output_dir, session_id, "chat_history.json" + ) + + if not os.path.exists(chat_history_file): return [] try: - with open(self.chat_history_file, "r", encoding="utf-8") as f: + with open(chat_history_file, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: - self.logger.error(f"Error loading chat history: {str(e)}") + self.logger.error( + f"Error loading chat history for session {session_id}: {str(e)}" + ) return [] def save_chat_contents(self, chat_contents): @@ -133,22 +148,48 @@ def save_chat_contents(self, chat_contents): except Exception as e: self.logger.error(f"Error saving chat contents: {str(e)}") - def load_chat_contents(self): - if not os.path.exists(self.chat_window_log_file): - self.logger.info(f"No chat log file found at {self.chat_window_log_file}") + def load_chat_contents(self, session_id=None): + """ + Load the chat contents for a given session. + If no session_id is provided, it loads the current session's contents. + """ + if session_id is None: + session_id = self.session_id + + if not session_id: + return [] + + chat_window_log_file = os.path.join( + self.output_dir, session_id, "chat_window_log.json" + ) + + if not os.path.exists(chat_window_log_file): return [] try: - with open(self.chat_window_log_file, "r", encoding="utf-8") as f: - return json.load(f) + with open(chat_window_log_file, "r", encoding="utf-8") as f: + contents = json.load(f) + + # Ensure each item has 'role' and 'content' keys + for item in contents: + if "sender" in item and "message" in item: + item["role"] = item.pop("sender") + item["content"] = item.pop("message") + + return contents except Exception as e: - self.logger.error(f"Error loading chat contents: {str(e)}") + self.logger.error( + f"Error loading chat contents for session {session_id}: {str(e)}" + ) return [] def set_session_id(self, session_id): self.session_id = session_id self.session_dir = os.path.join(self.output_dir, self.session_id) self.chat_history_file = os.path.join(self.session_dir, "chat_history.json") + self.chat_window_log_file = os.path.join( + self.session_dir, "chat_window_log.json" + ) self._ensure_output_dirs_exist() setup_logger(self.session_dir) self.logger = get_logger() diff --git a/codeaide/utils/session_manager.py b/codeaide/utils/session_manager.py new file mode 100644 index 0000000..a5f4fdc --- /dev/null +++ b/codeaide/utils/session_manager.py @@ -0,0 +1,132 @@ +import os +import json +from datetime import datetime +from typing import List, Dict, Optional +from codeaide.utils.logging_config import get_logger +from codeaide.utils.file_handler import FileHandler +from codeaide.utils.general_utils import generate_session_id + + +class SessionManager: + def __init__(self, base_dir: str, file_handler: FileHandler): + self.base_dir = base_dir + self.sessions_dir = file_handler.output_dir + self.logger = get_logger() + self.current_session_id = None + self.file_handler = file_handler + + def create_new_session(self, based_on: Optional[str] = None) -> str: + """Create a new session, optionally based on an existing one.""" + new_session_id = generate_session_id() + new_session_dir = os.path.join(self.sessions_dir, new_session_id) + os.makedirs(new_session_dir, exist_ok=True) + + self.file_handler.set_session_id(new_session_id) + self.current_session_id = new_session_id + + if based_on: + self._copy_session_contents(based_on, new_session_id) + default_summary = f"Continued from session {based_on}" + else: + default_summary = "New empty session" + + self._update_session_metadata( + new_session_id, summary=default_summary, based_on=based_on + ) + return new_session_id + + def load_session(self, session_id: str) -> None: + """Load an existing session.""" + session_dir = os.path.join(self.sessions_dir, session_id) + if not os.path.exists(session_dir): + raise ValueError(f"Session {session_id} does not exist.") + + self.file_handler.set_session_id(session_id) + self.current_session_id = session_id + + def get_all_sessions(self) -> List[Dict[str, str]]: + """Get a list of all non-empty sessions, sorted by most recent first.""" + sessions = [] + for session_id in os.listdir(self.sessions_dir): + metadata = self._load_session_metadata(session_id) + if metadata and metadata.get("summary") not in [ + "New session", + "New empty session", + ]: + sessions.append( + { + "id": session_id, + "summary": metadata.get("summary", "No summary"), + "last_modified": metadata.get("last_modified", "Unknown"), + } + ) + return sorted(sessions, key=lambda x: x["last_modified"], reverse=True) + + def update_session_summary(self, summary: str) -> None: + """Update the summary for the current session.""" + if not self.current_session_id: + raise ValueError("No current session.") + self._update_session_metadata(self.current_session_id, summary=summary) + + def _copy_session_contents(self, source_id: str, target_id: str) -> None: + """Copy contents from one session to another.""" + source_dir = os.path.join(self.sessions_dir, source_id) + target_dir = os.path.join(self.sessions_dir, target_id) + + for item in os.listdir(source_dir): + s = os.path.join(source_dir, item) + d = os.path.join(target_dir, item) + if os.path.isfile(s): + with open(s, "rb") as src, open(d, "wb") as dst: + dst.write(src.read()) + + def _update_session_metadata( + self, + session_id: str, + summary: Optional[str] = None, + based_on: Optional[str] = None, + ) -> None: + """Update or create metadata for a session.""" + metadata_file = os.path.join(self.sessions_dir, session_id, "metadata.json") + metadata = self._load_session_metadata(session_id) or {} + + metadata.update( + { + "last_modified": datetime.now().isoformat(), + "summary": summary or metadata.get("summary", "New session"), + } + ) + + if based_on: + metadata["based_on"] = based_on + + with open(metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + + def _load_session_metadata(self, session_id: str) -> Optional[Dict]: + """Load metadata for a session.""" + metadata_file = os.path.join(self.sessions_dir, session_id, "metadata.json") + if os.path.exists(metadata_file): + with open(metadata_file, "r") as f: + return json.load(f) + return None + + def get_current_session_id(self) -> Optional[str]: + """Get the current session ID.""" + return self.current_session_id + + def get_file_handler(self) -> FileHandler: + """Get the current FileHandler instance.""" + return self.file_handler + + def load_previous_session(self, previous_session_id: str) -> str: + """Load a previous session by creating a new session based on it.""" + new_session_id = self.create_new_session(based_on=previous_session_id) + self._copy_session_contents(previous_session_id, new_session_id) + self._update_session_metadata(new_session_id, based_on=previous_session_id) + return new_session_id + + def get_session_summary(self, session_id: str) -> Optional[str]: + """Get the summary for a specific session.""" + metadata = self._load_session_metadata(session_id) + return metadata.get("summary") if metadata else None