From 459f6f81658ff644037ae63fc8a1d8d7c41a60e9 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 15 Dec 2025 12:09:18 +0100 Subject: [PATCH] Some changes, updates and happy thoughts --- .gitignore | 3 +- README.md | 11 ++ oai.py | 362 ++++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 328 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index e347bf7..e853533 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ Pipfile.lock # Consider if you want to include or exclude ._* *~.nib *~.xib -README.md.old \ No newline at end of file +README.md.old +oai.zip \ No newline at end of file diff --git a/README.md b/README.md index c2b9a58..f1b82a9 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,17 @@ All configuration is stored in `~/.config/oai/`: /model ``` +**Paste from clipboard:** +Paste and send content to model +``` +/paste +``` + +Paste with prompt and send content to model +``` +/paste Analyze this text +``` + **Start Chatting:** ``` You> Hello, how are you? diff --git a/oai.py b/oai.py index 605be12..8923fd8 100644 --- a/oai.py +++ b/oai.py @@ -10,6 +10,8 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text +from rich.markdown import Markdown +from rich.live import Live from openrouter import OpenRouter import pyperclip import mimetypes @@ -22,9 +24,14 @@ import logging # Added missing import for logging from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory from rich.logging import RichHandler +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory app = typer.Typer() +# Application identification for OpenRouter +APP_NAME = "oAI" +APP_URL = "https://iurl.no/oai" + # Paths home = Path.home() config_dir = home / '.config' / 'oai' @@ -75,7 +82,7 @@ app_logger.setLevel(logging.INFO) # DB configuration database = config_dir / 'oai_config.db' DB_FILE = str(database) -version = '1.5' +version = '1.7' def create_table_if_not_exists(): """Ensure the config and conversation_sessions tables exist.""" @@ -123,6 +130,13 @@ def load_conversation(name: str) -> Optional[List[Dict[str, str]]]: return json.loads(result[0]) return None +def delete_conversation(name: str) -> int: + """Delete all conversation sessions with the given name. Returns number of deleted rows.""" + with sqlite3.connect(DB_FILE) as conn: + cursor = conn.execute('DELETE FROM conversation_sessions WHERE name = ?', (name,)) + conn.commit() + return cursor.rowcount + def list_conversations() -> List[Dict[str, Any]]: """List all saved conversations from DB with metadata.""" with sqlite3.connect(DB_FILE) as conn: @@ -264,11 +278,18 @@ 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 -# Fetch models +# Fetch models with app identification headers models_data = [] text_models = [] try: - headers = {"Authorization": f"Bearer {API_KEY}"} if API_KEY else {} + headers = { + "Authorization": f"Bearer {API_KEY}", + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } if API_KEY else { + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } response = requests.get(f"{OPENROUTER_BASE_URL}/models", headers=headers) response.raise_for_status() models_data = response.json()["data"] @@ -287,7 +308,11 @@ def get_credits(api_key: str, base_url: str = OPENROUTER_BASE_URL) -> Optional[D if not api_key: return None url = f"{base_url}/credits" - headers = {"Authorization": f"Bearer {api_key}"} + headers = { + "Authorization": f"Bearer {api_key}", + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } try: response = requests.get(url, headers=headers) response.raise_for_status() @@ -334,9 +359,11 @@ def chat(): total_cost = 0.0 message_count = 0 middle_out_enabled = False # Session-level middle-out transform flag + 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 - app_logger.info("Starting new chat session") # Log session start + app_logger.info("Starting new chat session with memory enabled") # Log session start if not API_KEY: console.print("[bold red]API key not found. Use '/config api'.[/]") @@ -365,7 +392,10 @@ def chat(): app_logger.warning(f"Startup credit alerts: {startup_alert_msg}") selected_model = selected_model_default + + # Initialize OpenRouter client client = OpenRouter(api_key=API_KEY) + if selected_model: console.print(f"[bold blue]Welcome to oAI![/] [bold red]Active model: {selected_model['name']}[/]") else: @@ -379,7 +409,7 @@ def chat(): while True: try: - user_input = session.prompt("You> ").strip() + user_input = session.prompt("You> ", auto_suggest=AutoSuggestFromHistory()).strip() 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 @@ -396,6 +426,89 @@ def chat(): console.print("[bold green]Retrying last prompt...[/]") app_logger.info(f"Retrying prompt: {last_prompt[:100]}...") user_input = last_prompt + elif user_input.lower().startswith("/memory"): + args = user_input[8:].strip() + if not args: + status = "enabled" if conversation_memory_enabled else "disabled" + history_count = len(session_history) - memory_start_index if conversation_memory_enabled and memory_start_index < len(session_history) else 0 + console.print(f"[bold blue]Conversation memory {status}.[/]") + if conversation_memory_enabled: + console.print(f"[dim blue]Tracking {history_count} message(s) since memory enabled.[/]") + else: + console.print(f"[dim yellow]Memory disabled. Each request is independent (saves tokens/cost).[/]") + continue + if args.lower() == "on": + conversation_memory_enabled = True + memory_start_index = len(session_history) # Remember where we started + 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}") + elif args.lower() == "off": + conversation_memory_enabled = False + console.print("[bold green]Conversation memory disabled. API calls will not include history (lower cost).[/]") + console.print(f"[dim yellow]Note: Messages are still saved locally but not sent to API.[/]") + app_logger.info("Conversation memory disabled") + else: + 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: + clipboard_content = pyperclip.paste() + except Exception as e: + console.print(f"[bold red]Failed to access clipboard: {e}[/]") + app_logger.error(f"Clipboard access error: {e}") + continue + + if not clipboard_content or not clipboard_content.strip(): + console.print("[bold red]Clipboard is empty.[/]") + 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_text = '\n'.join(preview_lines) + if len(clipboard_content.split('\n')) > 10: + preview_text += "\n... (content truncated for preview)" + + char_count = len(clipboard_content) + line_count = len(clipboard_content.split('\n')) + + console.print(Panel( + preview_text, + title=f"[bold cyan]📋 Clipboard Content Preview ({char_count} chars, {line_count} lines)[/]", + title_align="left", + 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}'[/]") + else: + 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)}") + + except UnicodeDecodeError: + console.print("[bold red]Clipboard contains non-text (binary) data. Only plain text is supported.[/]") + app_logger.error("Paste failed - clipboard contains binary data") + continue + except Exception as e: + console.print(f"[bold red]Error processing clipboard content: {e}[/]") + app_logger.error(f"Clipboard processing error: {e}") + continue + elif user_input.lower().startswith("/export"): args = user_input[8:].strip().split(maxsplit=1) if len(args) != 2: @@ -477,6 +590,9 @@ 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 total_input_tokens = 0 total_output_tokens = 0 total_cost = 0.0 @@ -484,6 +600,48 @@ def chat(): console.print(f"[bold green]Conversation '{conversation_name}' loaded with {len(session_history)} messages.[/]") app_logger.info(f"Conversation '{conversation_name}' loaded with {len(session_history)} messages") continue + elif user_input.lower().startswith("/delete"): + args = user_input[8:].strip() + if not args: + console.print("[bold red]Usage: /delete [/]") + 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) + if saved_conversations_cache and 1 <= conv_number <= len(saved_conversations_cache): + conversation_name = saved_conversations_cache[conv_number - 1]['name'] + console.print(f"[bold cyan]Deleting conversation #{conv_number}: '{conversation_name}'[/]") + else: + console.print(f"[bold red]Invalid conversation number: {conv_number}[/]") + console.print(f"[bold yellow]Use /list to see available conversations (1-{len(saved_conversations_cache) if saved_conversations_cache else 0})[/]") + continue + else: + conversation_name = args + + # Confirm deletion + try: + confirm = typer.confirm(f"Delete conversation '{conversation_name}'? This cannot be undone.", default=False) + if not confirm: + console.print("[bold yellow]Deletion cancelled.[/]") + continue + except (EOFError, KeyboardInterrupt): + console.print("\n[bold yellow]Deletion cancelled.[/]") + continue + + deleted_count = delete_conversation(conversation_name) + 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: + console.print(f"[bold red]Conversation '{conversation_name}' not found.[/]") + app_logger.warning(f"Delete failed for '{conversation_name}' - not found") + continue elif user_input.lower() == "/list": conversations = list_conversations() if not conversations: @@ -511,7 +669,7 @@ def chat(): formatted_time ) - console.print(Panel(table, title=f"[bold green]Saved Conversations ({len(conversations)} total)[/]", title_align="left", subtitle="[dim]Use /load or /load to load a conversation[/]", subtitle_align="right")) + console.print(Panel(table, title=f"[bold green]Saved Conversations ({len(conversations)} total)[/]", title_align="left", subtitle="[dim]Use /load or /delete to manage conversations[/]", subtitle_align="right")) app_logger.info(f"User viewed conversation list - {len(conversations)} conversations") continue elif user_input.lower() == "/prev": @@ -520,7 +678,9 @@ def chat(): continue current_index -= 1 prev_response = session_history[current_index]['response'] - console.print(Panel(prev_response, title=f"[bold green]Previous Response ({current_index + 1}/{len(session_history)})[/]")) + # 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}") continue elif user_input.lower() == "/next": @@ -529,7 +689,9 @@ def chat(): continue current_index += 1 next_response = session_history[current_index]['response'] - console.print(Panel(next_response, title=f"[bold green]Next Response ({current_index + 1}/{len(session_history)})[/]")) + # 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}") continue elif user_input.lower() == "/stats": @@ -571,6 +733,7 @@ def chat(): session_history = [] current_index = -1 session_system_prompt = "" + memory_start_index = 0 # Reset memory tracking total_input_tokens = 0 total_output_tokens = 0 total_cost = 0.0 @@ -692,6 +855,7 @@ def chat(): 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: @@ -782,7 +946,9 @@ def chat(): console.print("[bold red]Invalid input. Enter a number.[/]") else: DEFAULT_MODEL_ID = get_config('default_model') - table = Table("Setting", "Value", show_header=True, header_style="bold magenta") + memory_status = "Enabled" if conversation_memory_enabled else "Disabled" + memory_tracked = len(session_history) - memory_start_index if conversation_memory_enabled else 0 + table = Table("Setting", "Value", show_header=True, header_style="bold magenta", width=console.width - 10) table.add_row("API Key", API_KEY or "[Not set]") table.add_row("Base URL", OPENROUTER_BASE_URL or "[Not set]") table.add_row("DB Path", str(database) or "[Not set]") @@ -795,8 +961,11 @@ def chat(): table.add_row("Session System Prompt", session_system_prompt or "[Not set]") table.add_row("Cost Warning Threshold", f"${COST_WARNING_THRESHOLD:.4f}") table.add_row("Middle-out Transform", "Enabled" if middle_out_enabled else "Disabled") + table.add_row("Conversation Memory", f"{memory_status} ({memory_tracked} tracked)" if conversation_memory_enabled else memory_status) table.add_row("History Size", str(len(session_history))) table.add_row("Current History Index", str(current_index) if current_index >= 0 else "[None]") + table.add_row("App Name", APP_NAME) + table.add_row("App URL", APP_URL) credits = get_credits(API_KEY, OPENROUTER_BASE_URL) if credits: @@ -808,7 +977,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")) + console.print(Panel(table, title="[bold green]Current Configurations[/]", title_align="left", subtitle="[bold green]oAI Version %s" % version, subtitle_align="right")) continue if user_input.lower() == "/credits": @@ -824,15 +993,15 @@ def chat(): continue if user_input.lower() == "/clear": - os.system("clear" if os.name == "posix" else "cls") + clear_screen() DEFAULT_MODEL_ID = get_config('default_model') token_value = session_max_token if session_max_token != 0 else " Not set" console.print(f"[bold cyan]Token limits: Max= {MAX_TOKEN}, Session={token_value}[/]") - console.print("[bold blue]Active model[/] [bold red]%s[/]" %(DEFAULT_MODEL_ID)) + console.print("[bold blue]Active model[/] [bold red]%s[/]" %(str(selected_model["name"]) if selected_model else "None")) continue if user_input.lower() == "/help": - help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan") + help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan", width=console.width - 10) # ===== SESSION COMMANDS ===== help_table.add_row( @@ -850,11 +1019,21 @@ def chat(): "Show this help menu with all available commands.", "/help" ) + help_table.add_row( + "/memory [on|off]", + "Toggle conversation memory. ON sends history (AI remembers), OFF sends only current message (saves cost).", + "/memory\n/memory off" + ) help_table.add_row( "/next", "View the next response in history.", "/next" ) + help_table.add_row( + "/paste [prompt]", + "Paste plain text/code from clipboard and send to AI. Optional prompt can be added.", + "/paste\n/paste Explain this code" + ) help_table.add_row( "/prev", "View the previous response in history.", @@ -958,6 +1137,11 @@ def chat(): "", "" ) + help_table.add_row( + "/delete ", + "Delete a saved conversation by name or number (from /list). Requires confirmation.", + "/delete my_chat\n/delete 3" + ) help_table.add_row( "/export ", "Export conversation to file. Formats: md (Markdown), json (JSON), html (HTML).", @@ -998,14 +1182,19 @@ def chat(): # ===== FILE ATTACHMENTS ===== help_table.add_row( - "[bold yellow]━━━ FILE ATTACHMENTS ━━━[/]", + "[bold yellow]━━━ INPUT METHODS ━━━[/]", "", "" ) help_table.add_row( "@/path/to/file", - "Attach code files ('.py', '.js', '.ts', '.cs', '.java', '.c', '.cpp', '.h', '.hpp', '.rb', '.ruby', '.php', '.swift', '.kt', '.kts', '.go', '.sh', '.bat', '.ps1', '.R', '.scala', '.pl', '.lua', '.dart', '.elm', '.xml', '.json', '.yaml', '.yml', '.md', '.txt') or images to messages using @path syntax.", - "Debug this @script.py\nAnalyze @image.png" + "Attach files to messages: images (PNG, JPG, etc.), PDFs, and code files (.py, .js, etc.).", + "Debug @script.py\nSummarize @document.pdf\nAnalyze @image.png" + ) + help_table.add_row( + "Clipboard paste", + "Use /paste to send clipboard content (plain text/code) to AI.", + "/paste\n/paste Explain this" ) # ===== EXIT ===== @@ -1024,7 +1213,7 @@ def chat(): help_table, title="[bold cyan]oAI Chat Help (Version %s)[/]" % version, title_align="center", - subtitle="💡 Tip: Commands are case-insensitive • Visit: https://iurl.no/oai", + subtitle="💡 Tip: Commands are case-insensitive • Memory ON by default (toggle with /memory) • Visit: https://iurl.no/oai", subtitle_align="center", border_style="cyan" )) @@ -1034,7 +1223,7 @@ def chat(): console.print("[bold yellow]Select a model first with '/model'.[/]") continue - # Process file attachments (unchanged but log details) + # Process file attachments with PDF support content_blocks = [] text_part = user_input file_attachments = [] @@ -1053,20 +1242,48 @@ def chat(): try: with open(expanded_path, 'rb') as f: file_data = f.read() + + # Handle images if mime_type and mime_type.startswith('image/'): - if "image" not in selected_model.get("modalities", []): + modalities = selected_model.get("architecture", {}).get("input_modalities", []) + if "image" not in modalities: console.print("[bold red]Selected model does not support image attachments.[/]") + console.print(f"[dim yellow]Supported modalities: {', '.join(modalities) if modalities else 'text only'}[/]") continue b64_data = base64.b64encode(file_data).decode('utf-8') content_blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64_data}"}}) + console.print(f"[dim green]✓ Image attached: {os.path.basename(expanded_path)} ({file_size / 1024:.1f} KB)[/]") + + # 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.[/]") + console.print(f"[dim yellow]Supported modalities: {', '.join(modalities) if modalities else 'text only'}[/]") + continue + b64_data = base64.b64encode(file_data).decode('utf-8') + content_blocks.append({"type": "image_url", "image_url": {"url": f"data:application/pdf;base64,{b64_data}"}}) + console.print(f"[dim green]✓ PDF attached: {os.path.basename(expanded_path)} ({file_size / 1024:.1f} KB)[/]") + + # Handle code/text files elif (mime_type == 'text/plain' or file_ext in SUPPORTED_CODE_EXTENSIONS): text_content = file_data.decode('utf-8') content_blocks.append({"type": "text", "text": f"Code File: {os.path.basename(expanded_path)}\n\n{text_content}"}) + console.print(f"[dim green]✓ Code file attached: {os.path.basename(expanded_path)} ({file_size / 1024:.1f} KB)[/]") + else: - console.print(f"[bold red]Unsupported file type ({mime_type}) for {expanded_path}. Supported types: images, plain text, and code files (.py, .js, etc.).[/]") + console.print(f"[bold red]Unsupported file type ({mime_type}) for {expanded_path}.[/]") + console.print("[bold yellow]Supported types: images (PNG, JPG, etc.), PDFs, and code files (.py, .js, etc.)[/]") continue + file_attachments.append(file_path) - app_logger.info(f"File attached: {os.path.basename(expanded_path)}, Type: {mime_type}, Size: {file_size} KB") + app_logger.info(f"File attached: {os.path.basename(expanded_path)}, Type: {mime_type or file_ext}, Size: {file_size / 1024:.1f} KB") + except UnicodeDecodeError: + console.print(f"[bold red]Cannot decode {expanded_path} as UTF-8. File may be binary or use unsupported encoding.[/]") + app_logger.error(f"UTF-8 decode error for {expanded_path}") + continue except Exception as e: console.print(f"[bold red]Error reading file {expanded_path}: {e}[/]") app_logger.error(f"File read error for {expanded_path}: {e}") @@ -1083,12 +1300,40 @@ def chat(): console.print("[bold red]Prompt cannot be empty.[/]") continue - # Prepare API messages and params - api_messages = [{"role": "user", "content": message_content}] + # Build API messages with conversation history if memory is enabled + api_messages = [] + + # Add system prompt if set if session_system_prompt: - api_messages.insert(0, {"role": "system", "content": 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({ + "role": "user", + "content": history_entry['prompt'] + }) + api_messages.append({ + "role": "assistant", + "content": history_entry['response'] + }) + + # Add current user message + api_messages.append({"role": "user", "content": message_content}) - api_params = {"model": selected_model["id"], "messages": api_messages, "stream": STREAM_ENABLED == "on"} + # Build API params with app identification headers (using http_headers) + api_params = { + "model": selected_model["id"], + "messages": api_messages, + "stream": STREAM_ENABLED == "on", + "http_headers": { + "HTTP-Referer": APP_URL, + "X-Title": APP_NAME + } + } if session_max_token > 0: api_params["max_tokens"] = session_max_token if middle_out_enabled: @@ -1096,12 +1341,15 @@ def chat(): # Log API request file_count = len(file_attachments) - app_logger.info(f"API Request: Model '{selected_model['id']}', Prompt length: {len(text_part)} chars, {file_count} file(s) attached, Transforms: middle-out {'enabled' if middle_out_enabled else 'disabled'}.") + history_messages_count = len(session_history) - memory_start_index if conversation_memory_enabled else 0 + memory_status = "ON" if conversation_memory_enabled else "OFF" + app_logger.info(f"API Request: Model '{selected_model['id']}', 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 is_streaming = STREAM_ENABLED == "on" if is_streaming: - console.print("[bold green]Streaming response... (Press Ctrl+C to cancel)[/]") + console.print("[bold green]Streaming response...[/] [dim](Press Ctrl+C to cancel)[/]") + console.print("") # Add spacing before response else: console.print("[bold green]Thinking...[/]", end="\r") @@ -1119,17 +1367,23 @@ def chat(): full_response = "" if is_streaming: try: - for chunk in response: - if hasattr(chunk, 'error') and chunk.error: - console.print(f"\n[bold red]Stream error: {chunk.error.message}[/]") - app_logger.error(f"Stream error: {chunk.error.message}") - break - if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content: - content_chunk = chunk.choices[0].delta.content - print(content_chunk, end="", flush=True) - full_response += content_chunk - if full_response: - console.print() + # 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: + console.print(f"\n[bold red]Stream error: {chunk.error.message}[/]") + app_logger.error(f"Stream error: {chunk.error.message}") + break + 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: console.print("\n[bold yellow]Streaming cancelled![/]") app_logger.info("Streaming cancelled by user") @@ -1139,7 +1393,11 @@ def chat(): console.print(f"\r{' ' * 20}\r", end="") if full_response: - console.print(Panel(full_response, title="[bold green]AI Response[/]", title_align="left")) + # Render response with proper markdown formatting + if not is_streaming: # Only show panel for non-streaming (streaming already displayed) + 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 @@ -1155,10 +1413,15 @@ def chat(): message_count += 1 # Log response metrics - app_logger.info(f"[bold green]Response: Tokens - I:{input_tokens} O:{output_tokens} T:{input_tokens + output_tokens}, Cost: ${msg_cost:.4f}, Time: {response_time:.2f}s[/]") + 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") - # Per-message metrics display - console.print(f"[dim blue]Metrics: {input_tokens + output_tokens} tokens, ${msg_cost:.4f}, {response_time:.2f}s. Session total: {total_input_tokens + total_output_tokens} tokens, ${total_cost:.4f}[/]") + # Per-message metrics display with context info + 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 "" + else: + context_info = ", Memory: OFF" + console.print(f"\n[dim blue]📊 Metrics: {input_tokens + output_tokens} tokens | ${msg_cost:.4f} | {response_time:.2f}s{context_info} | Session: {total_input_tokens + total_output_tokens} tokens | ${total_cost:.4f}[/]") # Cost and credit alerts warnings = [] @@ -1169,17 +1432,22 @@ def chat(): warning_alerts = check_credit_alerts(credits_data) warnings.extend(warning_alerts) if warnings: - warning_text = '|'.join(warnings) - console.print(f"[bold red]⚠️ {warning_text}[/]") + warning_text = ' | '.join(warnings) + 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: ").strip().lower() + copy_choice = input("💾 Type 'c' to copy response, or press Enter to continue: ").strip().lower() if copy_choice == "c": pyperclip.copy(full_response) - console.print("[bold green]Response copied![/]") + console.print("[bold green]✅ Response copied to clipboard![/]") except (EOFError, KeyboardInterrupt): pass + + # Add spacing after interaction + console.print("") else: console.print("[bold red]No response received.[/]") app_logger.error("No response from API")