diff --git a/.gitignore b/.gitignore index c1f0f04..107b564 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build* compiled/ images/oai-iOS-Default-1024x1024@1x.png images/oai.icon/ +b0.sh \ No newline at end of file diff --git a/oai.py b/oai.py index 0f75fd4..9bf2cc4 100644 --- a/oai.py +++ b/oai.py @@ -20,11 +20,17 @@ import re import sqlite3 import json import datetime -import logging # Added missing import for logging +import logging +from logging.handlers import RotatingFileHandler # Added for log rotation from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory from rich.logging import RichHandler from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from packaging import version as pkg_version +import io # Added for custom handler + +# App version. Changes by author with new releases. +version = '1.9.5' app = typer.Typer() @@ -71,27 +77,9 @@ LOW_CREDIT_RATIO = 0.1 # Warn if credits left < 10% of total LOW_CREDIT_AMOUNT = 1.0 # Warn if credits left < $1 in absolute terms HIGH_COST_WARNING = "cost_warning_threshold" # Configurable key for cost threshold, default $0.01 -# Setup Rich-powered logging to file as per [rich.readthedocs.io](https://rich.readthedocs.io/en/latest/logging.html) -log_console = Console(file=open(log_file, 'a'), width=120) # Append to log file, wider for tracebacks -handler = RichHandler( - console=log_console, - level=logging.INFO, - rich_tracebacks=True, - tracebacks_suppress=['requests', 'openrouter', 'urllib3', 'httpx', 'openai'] # Suppress irrelevant tracebacks for cleaner logs -) -logging.basicConfig( - level=logging.NOTSET, # Let handler control what gets written - format="%(message)s", # Rich formats it - datefmt="[%X]", - handlers=[handler] -) -app_logger = logging.getLogger("oai_app") -app_logger.setLevel(logging.INFO) - # DB configuration database = config_dir / 'oai_config.db' DB_FILE = str(database) -version = '1.9' def create_table_if_not_exists(): """Ensure the config and conversation_sessions tables exist.""" @@ -122,6 +110,124 @@ def set_config(key: str, value: str): conn.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', (key, value)) conn.commit() +# ============================================================================ +# ROTATING RICH HANDLER - Combines RotatingFileHandler with Rich formatting +# ============================================================================ +class RotatingRichHandler(RotatingFileHandler): + """Custom handler that combines RotatingFileHandler with Rich formatting.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Create a Rich console that writes to a string buffer + self.rich_console = Console(file=io.StringIO(), width=120, force_terminal=False) + self.rich_handler = RichHandler( + console=self.rich_console, + show_time=True, + show_path=True, + rich_tracebacks=True, + tracebacks_suppress=['requests', 'openrouter', 'urllib3', 'httpx', 'openai'] + ) + + def emit(self, record): + try: + # Let RichHandler format the record + self.rich_handler.emit(record) + + # Get the formatted output from the string buffer + output = self.rich_console.file.getvalue() + + # Clear the buffer for next use + self.rich_console.file.seek(0) + self.rich_console.file.truncate(0) + + # Write the Rich-formatted output to our rotating file + if output: + self.stream.write(output) + self.flush() + + except Exception: + self.handleError(record) + +# Get log configuration from DB +LOG_MAX_SIZE_MB = int(get_config('log_max_size_mb') or "10") +LOG_BACKUP_COUNT = int(get_config('log_backup_count') or "2") + +# Create the custom rotating handler +app_handler = RotatingRichHandler( + filename=str(log_file), + maxBytes=LOG_MAX_SIZE_MB * 1024 * 1024, # Convert MB to bytes + backupCount=LOG_BACKUP_COUNT, + encoding='utf-8' +) + +logging.basicConfig( + level=logging.NOTSET, + format="%(message)s", # Rich formats it + datefmt="[%X]", + handlers=[app_handler] +) + +app_logger = logging.getLogger("oai_app") +app_logger.setLevel(logging.INFO) + +# ============================================================================ +# END OF LOGGING SETUP +# ============================================================================ + +logger = logging.getLogger(__name__) + +def check_for_updates(current_version: str) -> str: + """ + Check if a new version is available using semantic versioning. + + Returns: + Formatted status string for display + """ + try: + response = requests.get( + 'https://gitlab.pm/api/v1/repos/rune/oai/releases/latest', + headers={"Content-Type": "application/json"}, + timeout=1.0, + allow_redirects=True + ) + response.raise_for_status() + + data = response.json() + version_online = data.get('tag_name', '').lstrip('v') + + if not version_online: + logger.warning("No version found in API response") + return f"[bold green]oAI version {current_version}[/]" + + current = pkg_version.parse(current_version) + latest = pkg_version.parse(version_online) + + if latest > current: + logger.info(f"Update available: {current_version} → {version_online}") + return f"[bold green]oAI version {current_version} [/][bold red](Update available: {current_version} → {version_online})[/]" + else: + logger.debug(f"Already up to date: {current_version}") + return f"[bold green]oAI version {current_version} (up to date)[/]" + + except requests.exceptions.HTTPError as e: + logger.warning(f"HTTP error checking for updates: {e.response.status_code}") + return f"[bold green]oAI version {current_version}[/]" + except requests.exceptions.ConnectionError: + logger.warning("Network error checking for updates (offline?)") + return f"[bold green]oAI version {current_version}[/]" + except requests.exceptions.Timeout: + logger.warning("Timeout checking for updates") + return f"[bold green]oAI version {current_version}[/]" + except requests.exceptions.RequestException as e: + logger.warning(f"Request error checking for updates: {type(e).__name__}") + return f"[bold green]oAI version {current_version}[/]" + except (KeyError, ValueError) as e: + logger.warning(f"Invalid API response checking for updates: {e}") + return f"[bold green]oAI version {current_version}[/]" + except Exception as e: + logger.error(f"Unexpected error checking for updates: {e}") + return f"[bold green]oAI version {current_version}[/]" + def save_conversation(name: str, data: List[Dict[str, str]]): """Save conversation history to DB.""" timestamp = datetime.datetime.now().isoformat() @@ -309,6 +415,7 @@ STREAM_ENABLED = get_config('stream_enabled') or "on" DEFAULT_MODEL_ID = get_config('default_model') MAX_TOKEN = int(get_config('max_token') or "100000") COST_WARNING_THRESHOLD = float(get_config(HIGH_COST_WARNING) or "0.01") # Configurable cost threshold for alerts +DEFAULT_ONLINE_MODE = get_config('default_online_mode') or "off" # New: Default online mode setting # Fetch models with app identification headers models_data = [] @@ -416,16 +523,15 @@ def display_paginated_table(table: Table, title: str): return # Separate header from data rows - # Typically the first 3 lines are: top border, header row, separator header_lines = [] data_lines = [] - # Find where the header ends (usually after the first horizontal line after header text) + # Find where the header ends header_end_index = 0 found_header_text = False for i, line_segments in enumerate(all_lines): - # Check if this line contains header-style text (bold/magenta usually) + # Check if this line contains header-style text has_header_style = any( seg.style and ('bold' in str(seg.style) or 'magenta' in str(seg.style)) for seg in line_segments @@ -437,21 +543,20 @@ def display_paginated_table(table: Table, title: str): # After finding header text, the next line with box-drawing chars is the separator if found_header_text and i > 0: line_text = ''.join(seg.text for seg in line_segments) - # Check for horizontal line characters (─ ━ ╌ etc.) if any(char in line_text for char in ['─', '━', '┼', '╪', '┤', '├']): header_end_index = i break # If we found a header separator, split there if header_end_index > 0: - header_lines = all_lines[:header_end_index + 1] # Include the separator + header_lines = all_lines[:header_end_index + 1] data_lines = all_lines[header_end_index + 1:] else: # Fallback: assume first 3 lines are header header_lines = all_lines[:min(3, len(all_lines))] data_lines = all_lines[min(3, len(all_lines)):] - # Calculate how many data lines fit per page (accounting for header) + # Calculate how many data lines fit per page lines_per_page = terminal_height - len(header_lines) # Display with pagination @@ -469,7 +574,7 @@ def display_paginated_table(table: Table, title: str): for line_segments in header_lines: for segment in line_segments: console.print(segment.text, style=segment.style, end="") - console.print() # New line after each row + console.print() # Calculate how many data lines to show on this page end_line = min(current_line + lines_per_page, len(data_lines)) @@ -478,7 +583,7 @@ def display_paginated_table(table: Table, title: str): for line_segments in data_lines[current_line:end_line]: for segment in line_segments: console.print(segment.text, style=segment.style, end="") - console.print() # New line after each row + console.print() # Update position current_line = end_line @@ -492,20 +597,16 @@ def display_paginated_table(table: Table, title: str): import tty import termios - # Save terminal settings fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: - # Set terminal to raw mode to read single character tty.setraw(fd) char = sys.stdin.read(1) - # If not space, break pagination if char != ' ': break finally: - # Restore terminal settings termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) except: # Fallback for Windows or if termios not available @@ -513,12 +614,11 @@ def display_paginated_table(table: Table, title: str): if input_char != '': break else: - # No more content break @app.command() def chat(): - global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD + global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD, DEFAULT_ONLINE_MODE, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT session_max_token = 0 session_system_prompt = "" session_history = [] @@ -531,9 +631,9 @@ def chat(): conversation_memory_enabled = True # Memory ON by default memory_start_index = 0 # Track when memory was last enabled saved_conversations_cache = [] # Cache for /list results to use with /load by number - online_mode_enabled = False # Online mode (web search) disabled by default + online_mode_enabled = DEFAULT_ONLINE_MODE == "on" # Initialize from config - app_logger.info("Starting new chat session with memory enabled") # Log session start + app_logger.info("Starting new chat session with memory enabled") if not API_KEY: console.print("[bold red]API key not found. Use '/config api'.[/]") @@ -567,7 +667,8 @@ def chat(): client = OpenRouter(api_key=API_KEY) if selected_model: - console.print(f"[bold blue]Welcome to oAI![/] [bold red]Active model: {selected_model['name']}[/]") + online_status = "enabled" if online_mode_enabled else "disabled" + console.print(f"[bold blue]Welcome to oAI![/] [bold red]Active model: {selected_model['name']}[/] [dim cyan](Online mode: {online_status})[/]") else: console.print("[bold blue]Welcome to oAI![/] [italic blue]Select a model with '/model'.[/]") @@ -584,14 +685,11 @@ def chat(): # Handle // escape sequence - convert to single / and treat as regular text if user_input.startswith("//"): user_input = user_input[1:] # Remove first slash, keep the rest - # Don't process as command, jump to message processing - # Check for unknown commands (starts with / but not a valid command) + # Check for unknown commands elif user_input.startswith("/") and user_input.lower() not in ["exit", "quit", "bye"]: - # Extract command (first word after /) command_word = user_input.split()[0].lower() if user_input.split() else user_input.lower() - # Check if it's a valid command or partial match if not any(command_word.startswith(cmd) for cmd in VALID_COMMANDS): console.print(f"[bold red]Unknown command: {command_word}[/]") console.print("[bold yellow]Type /help to see all available commands.[/]") @@ -600,7 +698,7 @@ def chat(): if user_input.lower() in ["exit", "quit", "bye"]: total_tokens = total_input_tokens + total_output_tokens - app_logger.info(f"Session ended. Total messages: {message_count}, Total tokens: {total_tokens}, Total cost: ${total_cost:.4f}") # Log session summary + app_logger.info(f"Session ended. Total messages: {message_count}, Total tokens: {total_tokens}, Total cost: ${total_cost:.4f}") console.print("[bold yellow]Goodbye![/]") return @@ -618,7 +716,9 @@ def chat(): args = user_input[8:].strip() if not args: status = "enabled" if online_mode_enabled else "disabled" + default_status = "enabled" if DEFAULT_ONLINE_MODE == "on" else "disabled" console.print(f"[bold blue]Online mode (web search) {status}.[/]") + console.print(f"[dim blue]Default setting: {default_status} (use '/config online on|off' to change)[/]") if selected_model: if supports_online_mode(selected_model): console.print(f"[dim green]Current model '{selected_model['name']}' supports online mode.[/]") @@ -635,12 +735,12 @@ def chat(): app_logger.warning(f"Online mode activation failed - model {selected_model['id']} doesn't support it") continue online_mode_enabled = True - console.print("[bold green]Online mode enabled. Model will use web search capabilities.[/]") + console.print("[bold green]Online mode enabled for this session. Model will use web search capabilities.[/]") console.print(f"[dim blue]Effective model ID: {get_effective_model_id(selected_model['id'], True)}[/]") app_logger.info(f"Online mode enabled for model {selected_model['id']}") elif args.lower() == "off": online_mode_enabled = False - console.print("[bold green]Online mode disabled. Model will not use web search.[/]") + console.print("[bold green]Online mode disabled for this session. Model will not use web search.[/]") if selected_model: console.print(f"[dim blue]Effective model ID: {selected_model['id']}[/]") app_logger.info("Online mode disabled") @@ -660,7 +760,7 @@ def chat(): continue if args.lower() == "on": conversation_memory_enabled = True - memory_start_index = len(session_history) # Remember where we started + memory_start_index = len(session_history) console.print("[bold green]Conversation memory enabled. Will remember conversations from this point forward.[/]") console.print(f"[dim blue]Memory will track messages starting from index {memory_start_index}.[/]") app_logger.info(f"Conversation memory enabled at index {memory_start_index}") @@ -673,7 +773,6 @@ def chat(): console.print("[bold yellow]Usage: /memory on|off (or /memory to view status)[/]") continue elif user_input.lower().startswith("/paste"): - # Get optional prompt after /paste optional_prompt = user_input[7:].strip() try: @@ -688,13 +787,10 @@ def chat(): app_logger.warning("Paste attempted with empty clipboard") continue - # Validate it's text (check if it's valid UTF-8 and printable) try: - # Try to encode/decode to ensure it's valid text clipboard_content.encode('utf-8') - # Show preview of pasted content - preview_lines = clipboard_content.split('\n')[:10] # First 10 lines + preview_lines = clipboard_content.split('\n')[:10] preview_text = '\n'.join(preview_lines) if len(clipboard_content.split('\n')) > 10: preview_text += "\n... (content truncated for preview)" @@ -709,7 +805,6 @@ def chat(): border_style="cyan" )) - # Build the final prompt if optional_prompt: final_prompt = f"{optional_prompt}\n\n```\n{clipboard_content}\n```" console.print(f"[dim blue]Sending with prompt: '{optional_prompt}'[/]") @@ -717,7 +812,6 @@ def chat(): final_prompt = clipboard_content console.print("[dim blue]Sending clipboard content without additional prompt[/]") - # Set user_input to the pasted content so it gets processed normally user_input = final_prompt app_logger.info(f"Pasted content from clipboard: {char_count} chars, {line_count} lines, with prompt: {bool(optional_prompt)}") @@ -745,13 +839,11 @@ def chat(): console.print("[bold red]No conversation history to export.[/]") continue - # Validate format if export_format not in ['md', 'json', 'html']: console.print("[bold red]Invalid format. Use: md, json, or html[/]") continue try: - # Generate export content if export_format == 'md': content = export_as_markdown(session_history, session_system_prompt) elif export_format == 'json': @@ -759,7 +851,6 @@ def chat(): elif export_format == 'html': content = export_as_html(session_history, session_system_prompt) - # Write to file export_path = Path(filename).expanduser() with open(export_path, 'w', encoding='utf-8') as f: f.write(content) @@ -790,7 +881,6 @@ def chat(): console.print("[bold yellow]Tip: Use /list to see numbered conversations[/]") continue - # Check if input is a number conversation_name = None if args.isdigit(): conv_number = int(args) @@ -811,9 +901,8 @@ def chat(): continue session_history = loaded_data current_index = len(session_history) - 1 - # When loading, reset memory tracking if memory is enabled if conversation_memory_enabled: - memory_start_index = 0 # Include all loaded messages in memory + memory_start_index = 0 total_input_tokens = 0 total_output_tokens = 0 total_cost = 0.0 @@ -828,7 +917,6 @@ def chat(): console.print("[bold yellow]Tip: Use /list to see numbered conversations[/]") continue - # Check if input is a number conversation_name = None if args.isdigit(): conv_number = int(args) @@ -842,7 +930,6 @@ def chat(): else: conversation_name = args - # Confirm deletion try: confirm = typer.confirm(f"Delete conversation '{conversation_name}'? This cannot be undone.", default=False) if not confirm: @@ -856,7 +943,6 @@ def chat(): if deleted_count > 0: console.print(f"[bold green]Conversation '{conversation_name}' deleted ({deleted_count} version(s) removed).[/]") app_logger.info(f"Conversation '{conversation_name}' deleted - {deleted_count} version(s)") - # Refresh cache if deleted conversation was in it if saved_conversations_cache: saved_conversations_cache = [c for c in saved_conversations_cache if c['name'] != conversation_name] else: @@ -871,12 +957,10 @@ def chat(): saved_conversations_cache = [] continue - # Update cache for /load command saved_conversations_cache = conversations table = Table("No.", "Name", "Messages", "Last Saved", show_header=True, header_style="bold magenta") for idx, conv in enumerate(conversations, 1): - # Parse ISO timestamp and format it nicely try: dt = datetime.datetime.fromisoformat(conv['timestamp']) formatted_time = dt.strftime('%Y-%m-%d %H:%M:%S') @@ -899,7 +983,6 @@ def chat(): continue current_index -= 1 prev_response = session_history[current_index]['response'] - # Render as markdown with proper formatting md = Markdown(prev_response) console.print(Panel(md, title=f"[bold green]Previous Response ({current_index + 1}/{len(session_history)})[/]", title_align="left")) app_logger.debug(f"Viewed previous response at index {current_index}") @@ -910,7 +993,6 @@ def chat(): continue current_index += 1 next_response = session_history[current_index]['response'] - # Render as markdown with proper formatting md = Markdown(next_response) console.print(Panel(md, title=f"[bold green]Next Response ({current_index + 1}/{len(session_history)})[/]", title_align="left")) app_logger.debug(f"Viewed next response at index {current_index}") @@ -925,7 +1007,6 @@ def chat(): console.print(Panel(table, title="[bold green]Session Cost Summary[/]", title_align="left")) app_logger.info(f"User viewed stats: {stats}") - # Cost warnings in /stats warnings = check_credit_alerts(credits) if warnings: warning_text = '|'.join(warnings) @@ -954,7 +1035,7 @@ def chat(): session_history = [] current_index = -1 session_system_prompt = "" - memory_start_index = 0 # Reset memory tracking + memory_start_index = 0 total_input_tokens = 0 total_output_tokens = 0 total_cost = 0.0 @@ -976,7 +1057,6 @@ def chat(): console.print(f"[bold red]Model '{args}' not found.[/]") continue - # Display model info pricing = model_to_show.get("pricing", {}) architecture = model_to_show.get("architecture", {}) supported_params = ", ".join(model_to_show.get("supported_parameters", [])) or "None" @@ -1002,7 +1082,6 @@ def chat(): console.print(Panel(table, title=f"[bold green]Model Info: {model_to_show['name']}[/]", title_align="left")) continue - # Model selection with Image and Online columns elif user_input.startswith("/model"): app_logger.info("User initiated model selection") args = user_input[7:].strip() @@ -1014,14 +1093,12 @@ def chat(): console.print(f"[bold red]No models match '{search_term}'. Try '/model'.[/]") continue - # Create table with Image and Online columns table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta") for i, model in enumerate(filtered_models, 1): image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]" online_support = "[green]✓[/green]" if supports_online_mode(model) else "[red]✗[/red]" table.add_row(str(i), model["name"], model["id"], image_support, online_support) - # Use pagination for the table title = f"[bold green]Available Models ({'All' if not search_term else f'Search: {search_term}'})[/]" display_paginated_table(table, title) @@ -1032,14 +1109,19 @@ def chat(): break if 1 <= choice <= len(filtered_models): selected_model = filtered_models[choice - 1] - # Disable online mode when switching models (user must re-enable) - if online_mode_enabled: + + # Apply default online mode if model supports it + if supports_online_mode(selected_model) and DEFAULT_ONLINE_MODE == "on": + online_mode_enabled = True + console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]") + console.print("[dim cyan]✓ Online mode auto-enabled (default setting). Use '/online off' to disable for this session.[/]") + else: online_mode_enabled = False - console.print("[dim yellow]Note: Online mode auto-disabled when changing models.[/]") - console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]") - if supports_online_mode(selected_model): - console.print("[dim green]✓ This model supports online mode. Use '/online on' to enable web search.[/]") - app_logger.info(f"Model selected: {selected_model['name']} ({selected_model['id']})") + console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]") + if supports_online_mode(selected_model): + console.print("[dim green]✓ This model supports online mode. Use '/online on' to enable web search.[/]") + + app_logger.info(f"Model selected: {selected_model['name']} ({selected_model['id']}), Online: {online_mode_enabled}") break console.print("[bold red]Invalid choice. Try again.[/]") except ValueError: @@ -1047,7 +1129,6 @@ def chat(): continue elif user_input.startswith("/maxtoken"): - # (unchanged) args = user_input[10:].strip() if not args: console.print(f"[bold blue]Current session max tokens: {session_max_token}[/]") @@ -1067,7 +1148,6 @@ def chat(): continue elif user_input.startswith("/system"): - # (unchanged but added to history view) args = user_input[8:].strip() if not args: if session_system_prompt: @@ -1085,13 +1165,14 @@ def chat(): elif user_input.startswith("/config"): args = user_input[8:].strip().lower() + update = check_for_updates(version) + if args == "api": try: new_api_key = typer.prompt("Enter new API key") if new_api_key.strip(): set_config('api_key', new_api_key.strip()) API_KEY = new_api_key.strip() - # Reinitialize client with new API key client = OpenRouter(api_key=API_KEY) console.print("[bold green]API key updated![/]") else: @@ -1110,7 +1191,7 @@ def chat(): except Exception as e: console.print(f"[bold red]Error updating URL: {e}[/]") elif args.startswith("costwarning"): - sub_args = args[11:].strip() # Extract everything after "costwarning" + sub_args = args[11:].strip() if not sub_args: console.print(f"[bold blue]Stored cost warning threshold: ${COST_WARNING_THRESHOLD:.4f}[/]") continue @@ -1132,6 +1213,43 @@ def chat(): console.print(f"[bold green]Streaming {'enabled' if sub_args == 'on' else 'disabled'}.[/]") else: console.print("[bold yellow]Usage: /config stream on|off[/]") + elif args.startswith("log"): + sub_args = args[4:].strip() + if not sub_args: + console.print(f"[bold blue]Current log file size limit: {LOG_MAX_SIZE_MB} MB[/]") + console.print(f"[bold blue]Log backup count: {LOG_BACKUP_COUNT} files[/]") + console.print(f"[dim yellow]Total max disk usage: ~{LOG_MAX_SIZE_MB * (LOG_BACKUP_COUNT + 1)} MB[/]") + continue + try: + new_size_mb = int(sub_args) + if new_size_mb < 1: + console.print("[bold red]Log size must be at least 1 MB.[/]") + continue + if new_size_mb > 100: + console.print("[bold yellow]Warning: Log size > 100MB. Capping at 100MB.[/]") + new_size_mb = 100 + set_config('log_max_size_mb', str(new_size_mb)) + LOG_MAX_SIZE_MB = new_size_mb + console.print(f"[bold green]Log size limit set to {new_size_mb} MB.[/]") + console.print("[bold yellow]⚠️ Restart the application for this change to take effect.[/]") + app_logger.info(f"Log size limit updated to {new_size_mb} MB (requires restart)") + except ValueError: + console.print("[bold red]Invalid size. Provide a number in MB.[/]") + elif args.startswith("online"): + sub_args = args[7:].strip() + if not sub_args: + current_default = "enabled" if DEFAULT_ONLINE_MODE == "on" else "disabled" + console.print(f"[bold blue]Default online mode: {current_default}[/]") + console.print("[dim yellow]This sets the default for new models. Use '/online on|off' to override in current session.[/]") + continue + if sub_args in ["on", "off"]: + set_config('default_online_mode', sub_args) + DEFAULT_ONLINE_MODE = sub_args + console.print(f"[bold green]Default online mode {'enabled' if sub_args == 'on' else 'disabled'}.[/]") + console.print("[dim blue]Note: This affects new model selections. Current session unchanged.[/]") + app_logger.info(f"Default online mode set to {sub_args}") + else: + console.print("[bold yellow]Usage: /config online on|off[/]") elif args.startswith("maxtoken"): sub_args = args[9:].strip() if not sub_args: @@ -1163,14 +1281,12 @@ def chat(): console.print(f"[bold red]No models match '{search_term}'. Try without search.[/]") continue - # Create table with Image and Online columns table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta") for i, model in enumerate(filtered_models, 1): image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]" online_support = "[green]✓[/green]" if supports_online_mode(model) else "[red]✗[/red]" table.add_row(str(i), model["name"], model["id"], image_support, online_support) - # Use pagination for the table title = f"[bold green]Available Models for Default ({'All' if not search_term else f'Search: {search_term}'})[/]" display_paginated_table(table, title) @@ -1197,10 +1313,13 @@ def chat(): table.add_row("Base URL", OPENROUTER_BASE_URL or "[Not set]") table.add_row("DB Path", str(database) or "[Not set]") table.add_row("Logfile", str(log_file) or "[Not set]") + table.add_row("Log Size Limit", f"{LOG_MAX_SIZE_MB} MB") + table.add_row("Log Backups", str(LOG_BACKUP_COUNT)) table.add_row("Streaming", "Enabled" if STREAM_ENABLED == "on" else "Disabled") table.add_row("Default Model", DEFAULT_MODEL_ID or "[Not set]") table.add_row("Current Model", "[Not set]" if selected_model is None else str(selected_model["name"])) - table.add_row("Online Mode", "Enabled" if online_mode_enabled else "Disabled") + table.add_row("Default Online Mode", "Enabled" if DEFAULT_ONLINE_MODE == "on" else "Disabled") + table.add_row("Session Online Mode", "Enabled" if online_mode_enabled else "Disabled") table.add_row("Max Token", str(MAX_TOKEN)) table.add_row("Session Token", "[Not set]" if session_max_token == 0 else str(session_max_token)) table.add_row("Session System Prompt", session_system_prompt or "[Not set]") @@ -1222,7 +1341,7 @@ def chat(): table.add_row("Used Credits", "[Unavailable - Check API key]") table.add_row("Credits Left", "[Unavailable - Check API key]") - console.print(Panel(table, title="[bold green]Current Configurations[/]", title_align="left", subtitle="[bold green]oAI Version %s" % version, subtitle_align="right")) + console.print(Panel(table, title="[bold green]Current Configurations[/]", title_align="left", subtitle="%s" %(update), subtitle_align="right")) continue if user_input.lower() == "/credits": @@ -1250,7 +1369,7 @@ def chat(): if user_input.lower() == "/help": help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan", width=console.width - 10) - # ===== SESSION COMMANDS ===== + # SESSION COMMANDS help_table.add_row( "[bold yellow]━━━ SESSION COMMANDS ━━━[/]", "", @@ -1278,7 +1397,7 @@ def chat(): ) help_table.add_row( "/online [on|off]", - "Enable/disable online mode (web search) for current model. Only works with models that support tools.", + "Enable/disable online mode (web search) for current session. Overrides default setting.", "/online on\n/online off" ) help_table.add_row( @@ -1302,7 +1421,7 @@ def chat(): "/retry" ) - # ===== MODEL COMMANDS ===== + # MODEL COMMANDS help_table.add_row( "[bold yellow]━━━ MODEL COMMANDS ━━━[/]", "", @@ -1319,7 +1438,7 @@ def chat(): "/model\n/model gpt" ) - # ===== CONFIGURATION COMMANDS (ALPHABETICAL) ===== + # CONFIGURATION COMMANDS help_table.add_row( "[bold yellow]━━━ CONFIGURATION ━━━[/]", "", @@ -1340,6 +1459,11 @@ def chat(): "Set the cost warning threshold. Alerts when response exceeds this cost (in USD).", "/config costwarning 0.05" ) + help_table.add_row( + "/config log [size_mb]", + "Set log file size limit in MB. Older logs are rotated automatically. Requires restart.", + "/config log 20" + ) help_table.add_row( "/config maxtoken [value]", "Set stored max token limit (persisted in DB). View current if no value provided.", @@ -1350,6 +1474,11 @@ def chat(): "Set default model that loads on startup. Shows image and online capabilities. Doesn't change current session model.", "/config model gpt" ) + help_table.add_row( + "/config online [on|off]", + "Set default online mode for new model selections. Use '/online on|off' to override current session.", + "/config online on" + ) help_table.add_row( "/config stream [on|off]", "Enable or disable response streaming.", @@ -1361,7 +1490,7 @@ def chat(): "/config url" ) - # ===== TOKEN & SYSTEM COMMANDS ===== + # TOKEN & SYSTEM COMMANDS help_table.add_row( "[bold yellow]━━━ TOKEN & SYSTEM ━━━[/]", "", @@ -1383,7 +1512,7 @@ def chat(): "/system You are a Python expert" ) - # ===== CONVERSATION MANAGEMENT ===== + # CONVERSATION MANAGEMENT help_table.add_row( "[bold yellow]━━━ CONVERSATION MGMT ━━━[/]", "", @@ -1415,7 +1544,7 @@ def chat(): "/save my_chat" ) - # ===== MONITORING & STATS ===== + # MONITORING & STATS help_table.add_row( "[bold yellow]━━━ MONITORING & STATS ━━━[/]", "", @@ -1432,7 +1561,7 @@ def chat(): "/stats" ) - # ===== FILE ATTACHMENTS ===== + # INPUT METHODS help_table.add_row( "[bold yellow]━━━ INPUT METHODS ━━━[/]", "", @@ -1454,7 +1583,7 @@ def chat(): "//help sends '/help' as text" ) - # ===== EXIT ===== + # EXIT help_table.add_row( "[bold yellow]━━━ EXIT ━━━[/]", "", @@ -1514,7 +1643,6 @@ def chat(): # Handle PDFs elif mime_type == 'application/pdf' or file_ext == '.pdf': modalities = selected_model.get("architecture", {}).get("input_modalities", []) - # Check for various possible modality indicators for PDFs supports_pdf = any(mod in modalities for mod in ["document", "pdf", "file"]) if not supports_pdf: console.print("[bold red]Selected model does not support PDF attachments.[/]") @@ -1560,13 +1688,10 @@ def chat(): # Build API messages with conversation history if memory is enabled api_messages = [] - # Add system prompt if set if session_system_prompt: api_messages.append({"role": "system", "content": session_system_prompt}) - # Add conversation history only if memory is enabled (from memory start point onwards) if conversation_memory_enabled: - # Only include history from when memory was last enabled for i in range(memory_start_index, len(session_history)): history_entry = session_history[i] api_messages.append({ @@ -1578,13 +1703,12 @@ def chat(): "content": history_entry['response'] }) - # Add current user message api_messages.append({"role": "user", "content": message_content}) # Get effective model ID with :online suffix if enabled effective_model_id = get_effective_model_id(selected_model["id"], online_mode_enabled) - # Build API params with app identification headers (using http_headers) + # Build API params api_params = { "model": effective_model_id, "messages": api_messages, @@ -1606,17 +1730,17 @@ def chat(): online_status = "ON" if online_mode_enabled else "OFF" app_logger.info(f"API Request: Model '{effective_model_id}' (Online: {online_status}), Prompt length: {len(text_part)} chars, {file_count} file(s) attached, Memory: {memory_status}, History sent: {history_messages_count} messages, Transforms: middle-out {'enabled' if middle_out_enabled else 'disabled'}, App: {APP_NAME} ({APP_URL}).") - # Send and handle response with metrics and timing + # Send and handle response is_streaming = STREAM_ENABLED == "on" if is_streaming: console.print("[bold green]Streaming response...[/] [dim](Press Ctrl+C to cancel)[/]") if online_mode_enabled: console.print("[dim cyan]🌐 Online mode active - model has web search access[/]") - console.print("") # Add spacing before response + console.print("") else: console.print("[bold green]Thinking...[/]", end="\r") - start_time = time.time() # Start timing request + start_time = time.time() try: response = client.chat.send(**api_params) app_logger.info(f"API call successful for model '{effective_model_id}'") @@ -1625,12 +1749,11 @@ def chat(): app_logger.error(f"API Error: {type(e).__name__}: {e}") continue - response_time = time.time() - start_time # Calculate response time + response_time = time.time() - start_time full_response = "" if is_streaming: try: - # Use Live display for smooth streaming with proper wrapping with Live("", console=console, refresh_per_second=10, auto_refresh=True) as live: for chunk in response: if hasattr(chunk, 'error') and chunk.error: @@ -1640,11 +1763,9 @@ def chat(): if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content: content_chunk = chunk.choices[0].delta.content full_response += content_chunk - # Update live display with markdown rendering md = Markdown(full_response) live.update(md) - # Add newline after streaming completes console.print("") except KeyboardInterrupt: @@ -1656,15 +1777,14 @@ def chat(): console.print(f"\r{' ' * 20}\r", end="") if full_response: - # Render response with proper markdown formatting - if not is_streaming: # Only show panel for non-streaming (streaming already displayed) + if not is_streaming: md = Markdown(full_response) console.print(Panel(md, title="[bold green]AI Response[/]", title_align="left", border_style="green")) session_history.append({'prompt': user_input, 'response': full_response}) current_index = len(session_history) - 1 - # Process metrics for per-message display and session tracking + # Process metrics usage = getattr(response, 'usage', None) input_tokens = usage.input_tokens if usage and hasattr(usage, 'input_tokens') else 0 output_tokens = usage.output_tokens if usage and hasattr(usage, 'output_tokens') else 0 @@ -1675,10 +1795,9 @@ def chat(): total_cost += msg_cost message_count += 1 - # Log response metrics app_logger.info(f"Response: Tokens - I:{input_tokens} O:{output_tokens} T:{input_tokens + output_tokens}, Cost: ${msg_cost:.4f}, Time: {response_time:.2f}s, Online: {online_mode_enabled}") - # Per-message metrics display with context info + # Per-message metrics display if conversation_memory_enabled: context_count = len(session_history) - memory_start_index context_info = f", Context: {context_count} msg(s)" if context_count > 1 else "" @@ -1701,7 +1820,6 @@ def chat(): console.print(f"[bold red]⚠️ {warning_text}[/]") app_logger.warning(f"Warnings triggered: {warning_text}") - # Add spacing before copy prompt console.print("") try: copy_choice = input("💾 Type 'c' to copy response, or press Enter to continue: ").strip().lower() @@ -1711,7 +1829,6 @@ def chat(): except (EOFError, KeyboardInterrupt): pass - # Add spacing after interaction console.print("") else: console.print("[bold red]No response received.[/]") diff --git a/requirements.txt b/requirements.txt index 1ae029d..8a0a132 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,9 @@ markdown2==2.5.4 mdurl==0.1.2 natsort==8.4.0 openrouter==0.0.19 +packaging==25.0 pipreqs==0.4.13 -prompt_toolkit==3.0.52 +prompt-toolkit==3.0.52 Pygments==2.19.2 pyperclip==1.11.0 python-dateutil==2.9.0.post0 @@ -30,7 +31,7 @@ soupsieve==2.8 svgwrite==1.4.3 tqdm==4.67.1 typer==0.20.0 -typing_extensions==4.15.0 +typing-extensions==4.15.0 urllib3==2.5.0 wavedrom==2.0.3.post3 wcwidth==0.2.14