1 Commits
1.5 ... main

Author SHA1 Message Date
459f6f8165 Some changes, updates and happy thoughts 2025-12-15 12:09:18 +01:00
3 changed files with 328 additions and 48 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Pipfile.lock # Consider if you want to include or exclude
*~.nib
*~.xib
README.md.old
oai.zip

View File

@@ -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?

364
oai.py
View File

@@ -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 <name|number>[/]")
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 <number> or /load <name> 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 <number> or /delete <number> 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 <name|number>",
"Delete a saved conversation by name or number (from /list). Requires confirmation.",
"/delete my_chat\n/delete 3"
)
help_table.add_row(
"/export <format> <file>",
"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}]
if session_system_prompt:
api_messages.insert(0, {"role": "system", "content": session_system_prompt})
# Build API messages with conversation history if memory is enabled
api_messages = []
api_params = {"model": selected_model["id"], "messages": api_messages, "stream": STREAM_ENABLED == "on"}
# 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({
"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})
# 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")