diff --git a/oai.py b/oai.py index 9bf2cc4..1ab479e 100644 --- a/oai.py +++ b/oai.py @@ -60,6 +60,218 @@ VALID_COMMANDS = { '/info', '/model', '/maxtoken', '/system', '/config', '/credits', '/clear', '/cl', '/help' } +# Detailed command help database +COMMAND_HELP = { + '/clear': { + 'aliases': ['/cl'], + 'description': 'Clear the terminal screen for a clean interface.', + 'usage': '/clear\n/cl', + 'examples': [ + ('Clear screen', '/clear'), + ('Using short alias', '/cl'), + ], + 'notes': 'You can also use the keyboard shortcut Ctrl+L to clear the screen.' + }, + '/help': { + 'description': 'Display help information for commands.', + 'usage': '/help [command]', + 'examples': [ + ('Show all commands', '/help'), + ('Get help for a specific command', '/help /model'), + ('Get help for config', '/help /config'), + ], + 'notes': 'Use /help without arguments to see the full command list, or /help for detailed information about a specific command.' + }, + '/memory': { + 'description': 'Toggle conversation memory. When ON, the AI remembers conversation history. When OFF, each request is independent (saves tokens and cost).', + 'usage': '/memory [on|off]', + 'examples': [ + ('Check current memory status', '/memory'), + ('Enable conversation memory', '/memory on'), + ('Disable memory (save costs)', '/memory off'), + ], + 'notes': 'Memory is ON by default. Disabling memory reduces API costs but the AI won\'t remember previous messages. Messages are still saved locally for your reference.' + }, + '/online': { + 'description': 'Enable or disable online mode (web search capabilities) for the current session.', + 'usage': '/online [on|off]', + 'examples': [ + ('Check online mode status', '/online'), + ('Enable web search', '/online on'), + ('Disable web search', '/online off'), + ], + 'notes': 'Not all models support online mode. The model must have "tools" parameter support. This setting overrides the default online mode configured with /config online.' + }, + '/paste': { + 'description': 'Paste plain text or code from clipboard and send to the AI. Optionally add a prompt.', + 'usage': '/paste [prompt]', + 'examples': [ + ('Paste clipboard content', '/paste'), + ('Paste with a question', '/paste Explain this code'), + ('Paste and ask for review', '/paste Review this for bugs'), + ], + 'notes': 'Only plain text is supported. Binary clipboard data will be rejected. The clipboard content is shown as a preview before sending.' + }, + '/retry': { + 'description': 'Resend the last prompt from conversation history.', + 'usage': '/retry', + 'examples': [ + ('Retry last message', '/retry'), + ], + 'notes': 'Useful when you get an error or want a different response to the same prompt. Requires at least one message in history.' + }, + '/next': { + 'description': 'View the next response in conversation history.', + 'usage': '/next', + 'examples': [ + ('Navigate to next response', '/next'), + ], + 'notes': 'Navigate through conversation history. Use /prev to go backward.' + }, + '/prev': { + 'description': 'View the previous response in conversation history.', + 'usage': '/prev', + 'examples': [ + ('Navigate to previous response', '/prev'), + ], + 'notes': 'Navigate through conversation history. Use /next to go forward.' + }, + '/reset': { + 'description': 'Clear conversation history and reset system prompt. This resets all session metrics.', + 'usage': '/reset', + 'examples': [ + ('Reset conversation', '/reset'), + ], + 'notes': 'Requires confirmation. This clears all message history, resets the system prompt, and resets token/cost counters. Use when starting a completely new conversation topic.' + }, + '/info': { + 'description': 'Display detailed information about a model including pricing, capabilities, context length, and online support.', + 'usage': '/info [model_id]', + 'examples': [ + ('Show current model info', '/info'), + ('Show specific model info', '/info gpt-4o'), + ('Check model capabilities', '/info claude-3-opus'), + ], + 'notes': 'Without arguments, shows info for the currently selected model. Displays pricing per million tokens, supported modalities (text, image, etc.), and parameter support.' + }, + '/model': { + 'description': 'Select or change the AI model for the current session. Shows image and online capabilities.', + 'usage': '/model [search_term]', + 'examples': [ + ('List all models', '/model'), + ('Search for GPT models', '/model gpt'), + ('Search for Claude models', '/model claude'), + ], + 'notes': 'Models are numbered for easy selection. The table shows Image (✓ if model accepts images) and Online (✓ if model supports web search) columns.' + }, + '/config': { + 'description': 'View or modify application configuration settings.', + 'usage': '/config [setting] [value]', + 'examples': [ + ('View all settings', '/config'), + ('Set API key', '/config api'), + ('Set default model', '/config model'), + ('Enable streaming', '/config stream on'), + ('Set cost warning threshold', '/config costwarning 0.05'), + ('Set log level', '/config loglevel debug'), + ('Set default online mode', '/config online on'), + ], + 'notes': 'Available settings: api (API key), url (base URL), model (default model), stream (on/off), costwarning (threshold $), maxtoken (limit), online (default on/off), log (size MB), loglevel (debug/info/warning/error/critical).' + }, + '/maxtoken': { + 'description': 'Set a temporary session token limit (cannot exceed stored max token limit).', + 'usage': '/maxtoken [value]', + 'examples': [ + ('View current session limit', '/maxtoken'), + ('Set session limit to 2000', '/maxtoken 2000'), + ('Set to 50000', '/maxtoken 50000'), + ], + 'notes': 'This is a session-only setting and cannot exceed the stored max token limit (set with /config maxtoken). View without arguments to see current value.' + }, + '/system': { + 'description': 'Set or clear the session-level system prompt to guide AI behavior.', + 'usage': '/system [prompt|clear]', + 'examples': [ + ('View current system prompt', '/system'), + ('Set as Python expert', '/system You are a Python expert'), + ('Set as code reviewer', '/system You are a senior code reviewer. Focus on bugs and best practices.'), + ('Clear system prompt', '/system clear'), + ], + 'notes': 'System prompts influence how the AI responds throughout the session. Use "clear" to remove the current system prompt.' + }, + '/save': { + 'description': 'Save the current conversation history to the database.', + 'usage': '/save ', + 'examples': [ + ('Save conversation', '/save my_chat'), + ('Save with descriptive name', '/save python_debugging_2024'), + ], + 'notes': 'Saved conversations can be loaded later with /load. Use descriptive names to easily find them later.' + }, + '/load': { + 'description': 'Load a saved conversation from the database by name or number.', + 'usage': '/load ', + 'examples': [ + ('Load by name', '/load my_chat'), + ('Load by number from /list', '/load 3'), + ], + 'notes': 'Use /list to see numbered conversations. Loading a conversation replaces current history and resets session metrics.' + }, + '/delete': { + 'description': 'Delete a saved conversation from the database. Requires confirmation.', + 'usage': '/delete ', + 'examples': [ + ('Delete by name', '/delete my_chat'), + ('Delete by number from /list', '/delete 3'), + ], + 'notes': 'Use /list to see numbered conversations. This action cannot be undone!' + }, + '/list': { + 'description': 'List all saved conversations with numbers, message counts, and timestamps.', + 'usage': '/list', + 'examples': [ + ('Show saved conversations', '/list'), + ], + 'notes': 'Conversations are numbered for easy use with /load and /delete commands. Shows message count and last saved time.' + }, + '/export': { + 'description': 'Export the current conversation to a file in various formats.', + 'usage': '/export ', + 'examples': [ + ('Export as Markdown', '/export md notes.md'), + ('Export as JSON', '/export json conversation.json'), + ('Export as HTML', '/export html report.html'), + ], + 'notes': 'Available formats: md (Markdown), json (JSON), html (HTML). The export includes all messages and the system prompt if set.' + }, + '/stats': { + 'description': 'Display session statistics including tokens, costs, and credits.', + 'usage': '/stats', + 'examples': [ + ('View session statistics', '/stats'), + ], + 'notes': 'Shows total input/output tokens, total cost, average cost per message, and remaining credits. Also displays credit warnings if applicable.' + }, + '/credits': { + 'description': 'Display your OpenRouter account credits and usage.', + 'usage': '/credits', + 'examples': [ + ('Check credits', '/credits'), + ], + 'notes': 'Shows total credits, used credits, and credits left. Displays warnings if credits are low (< $1 or < 10% of total).' + }, + '/middleout': { + 'description': 'Enable or disable middle-out transform to compress prompts exceeding context size.', + 'usage': '/middleout [on|off]', + 'examples': [ + ('Check status', '/middleout'), + ('Enable compression', '/middleout on'), + ('Disable compression', '/middleout off'), + ], + 'notes': 'Middle-out transform intelligently compresses long prompts to fit within model context limits. Useful for very long conversations.' + }, +} + # Supported code file extensions SUPPORTED_CODE_EXTENSIONS = { '.py', '.js', '.ts', '.cs', '.java', '.c', '.cpp', '.h', '.hpp', @@ -77,6 +289,15 @@ 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 +# Valid log levels mapping +VALID_LOG_LEVELS = { + 'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARNING, + 'error': logging.ERROR, + 'critical': logging.CRITICAL +} + # DB configuration database = config_dir / 'oai_config.db' DB_FILE = str(database) @@ -151,6 +372,8 @@ class RotatingRichHandler(RotatingFileHandler): # 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") +LOG_LEVEL_STR = get_config('log_level') or "info" +LOG_LEVEL = VALID_LOG_LEVELS.get(LOG_LEVEL_STR.lower(), logging.INFO) # Create the custom rotating handler app_handler = RotatingRichHandler( @@ -168,7 +391,18 @@ logging.basicConfig( ) app_logger = logging.getLogger("oai_app") -app_logger.setLevel(logging.INFO) +app_logger.setLevel(LOG_LEVEL) + +def set_log_level(level_str: str) -> bool: + """Set the application log level. Returns True if successful.""" + global LOG_LEVEL, LOG_LEVEL_STR + level_str_lower = level_str.lower() + if level_str_lower not in VALID_LOG_LEVELS: + return False + LOG_LEVEL = VALID_LOG_LEVELS[level_str_lower] + LOG_LEVEL_STR = level_str_lower + app_logger.setLevel(LOG_LEVEL) + return True # ============================================================================ # END OF LOGGING SETUP @@ -408,6 +642,63 @@ def export_as_html(session_history: List[Dict[str, str]], session_system_prompt: return "\n".join(html_parts) +def show_command_help(command: str): + """Display detailed help for a specific command.""" + # Normalize command to ensure it starts with / + if not command.startswith('/'): + command = '/' + command + + # Check if command exists + if command not in COMMAND_HELP: + console.print(f"[bold red]Unknown command: {command}[/]") + console.print("[bold yellow]Type /help to see all available commands.[/]") + app_logger.warning(f"Help requested for unknown command: {command}") + return + + help_data = COMMAND_HELP[command] + + # Create detailed help panel + help_content = [] + + # Aliases if available + if 'aliases' in help_data: + aliases_str = ", ".join(help_data['aliases']) + help_content.append(f"[bold cyan]Aliases:[/] {aliases_str}") + help_content.append("") + + # Description + help_content.append(f"[bold cyan]Description:[/]") + help_content.append(help_data['description']) + help_content.append("") + + # Usage + help_content.append(f"[bold cyan]Usage:[/]") + help_content.append(f"[yellow]{help_data['usage']}[/]") + help_content.append("") + + # Examples + if 'examples' in help_data and help_data['examples']: + help_content.append(f"[bold cyan]Examples:[/]") + for desc, example in help_data['examples']: + help_content.append(f" [dim]{desc}:[/]") + help_content.append(f" [green]{example}[/]") + help_content.append("") + + # Notes + if 'notes' in help_data: + help_content.append(f"[bold cyan]Notes:[/]") + help_content.append(f"[dim]{help_data['notes']}[/]") + + console.print(Panel( + "\n".join(help_content), + title=f"[bold green]Help: {command}[/]", + title_align="left", + border_style="green", + width=console.width - 4 + )) + + app_logger.info(f"Displayed detailed help for command: {command}") + # Load configs API_KEY = get_config('api_key') OPENROUTER_BASE_URL = get_config('base_url') or "https://openrouter.ai/api/v1" @@ -618,7 +909,7 @@ def display_paginated_table(table: Table, title: str): @app.command() def chat(): - global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD, DEFAULT_ONLINE_MODE, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT + global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD, DEFAULT_ONLINE_MODE, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, LOG_LEVEL_STR session_max_token = 0 session_system_prompt = "" session_history = [] @@ -1213,11 +1504,28 @@ 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("loglevel"): + sub_args = args[8:].strip() + if not sub_args: + console.print(f"[bold blue]Current log level: {LOG_LEVEL_STR.upper()}[/]") + console.print(f"[dim yellow]Valid levels: debug, info, warning, error, critical[/]") + continue + if sub_args.lower() in VALID_LOG_LEVELS: + if set_log_level(sub_args): + set_config('log_level', sub_args.lower()) + console.print(f"[bold green]Log level set to: {sub_args.upper()}[/]") + app_logger.info(f"Log level changed to {sub_args.upper()}") + else: + console.print(f"[bold red]Failed to set log level.[/]") + else: + console.print(f"[bold red]Invalid log level: {sub_args}[/]") + console.print(f"[bold yellow]Valid levels: debug, info, warning, error, critical[/]") 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"[bold blue]Log level: {LOG_LEVEL_STR.upper()}[/]") console.print(f"[dim yellow]Total max disk usage: ~{LOG_MAX_SIZE_MB * (LOG_BACKUP_COUNT + 1)} MB[/]") continue try: @@ -1315,6 +1623,7 @@ def chat(): 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("Log Level", LOG_LEVEL_STR.upper()) 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"])) @@ -1366,7 +1675,15 @@ def chat(): console.print("[bold cyan]Online mode: Enabled (web search active)[/]") continue - if user_input.lower() == "/help": + if user_input.lower().startswith("/help"): + args = user_input[6:].strip() + + # If a specific command is requested + if args: + show_command_help(args) + continue + + # Otherwise show the full help menu help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan", width=console.width - 10) # SESSION COMMANDS @@ -1381,9 +1698,9 @@ def chat(): "/clear\n/cl" ) help_table.add_row( - "/help", - "Show this help menu with all available commands.", - "/help" + "/help [command]", + "Show this help menu or get detailed help for a specific command.", + "/help\n/help /model" ) help_table.add_row( "/memory [on|off]", @@ -1464,6 +1781,11 @@ def chat(): "Set log file size limit in MB. Older logs are rotated automatically. Requires restart.", "/config log 20" ) + help_table.add_row( + "/config loglevel [level]", + "Set log verbosity level. Valid levels: debug, info, warning, error, critical. Takes effect immediately.", + "/config loglevel debug\n/config loglevel warning" + ) help_table.add_row( "/config maxtoken [value]", "Set stored max token limit (persisted in DB). View current if no value provided.", @@ -1599,7 +1921,7 @@ def chat(): help_table, title="[bold cyan]oAI Chat Help (Version %s)[/]" % version, title_align="center", - subtitle="💡 Tip: Commands are case-insensitive • Memory ON by default (toggle with /memory) • Use // to escape / at start of input • Visit: https://iurl.no/oai", + subtitle="💡 Tip: Commands are case-insensitive • Use /help for detailed help • Memory ON by default • Use // to escape / • Visit: https://iurl.no/oai", subtitle_align="center", border_style="cyan" ))