New functionality and bugfixes.

Reviewed-on: #1
Co-authored-by: Rune Olsen <rune@rune.pm>
Co-committed-by: Rune Olsen <rune@rune.pm>
This commit was merged in pull request #1.
This commit is contained in:
2025-12-30 15:46:40 +01:00
committed by rune
parent a6f0edd9f3
commit 1ef7918291
2 changed files with 454 additions and 34 deletions

View File

@@ -23,7 +23,7 @@ oAI is a command-line chat application that provides an interactive interface to
- Python 3.7 or higher
- OpenRouter API key (get one at https://openrouter.ai)
## Screenshot
## Screenshot (<span style="font-size:0.8em;">from version 1.0</span>)
[<img src="https://gitlab.pm/rune/oai/raw/branch/main/images/screenshot_01.png">](https://gitlab.pm/rune/oai/src/branch/main/README.md)

486
oai.py
View File

@@ -30,7 +30,7 @@ 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'
version = '1.9.6'
app = typer.Typer()
@@ -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 <command> 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 <name>',
'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 <name|number>',
'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 <name|number>',
'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 <format> <filename>',
'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)
@@ -148,27 +369,131 @@ class RotatingRichHandler(RotatingFileHandler):
except Exception:
self.handleError(record)
# Get log configuration from DB
# ============================================================================
# LOGGING SETUP - MUST BE DONE AFTER CONFIG IS LOADED
# ============================================================================
# Load log configuration from DB FIRST (before creating handler)
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(
filename=str(log_file),
maxBytes=LOG_MAX_SIZE_MB * 1024 * 1024, # Convert MB to bytes
backupCount=LOG_BACKUP_COUNT,
encoding='utf-8'
)
# Global reference to the handler for dynamic reloading
app_handler = None
app_logger = None
logging.basicConfig(
level=logging.NOTSET,
format="%(message)s", # Rich formats it
datefmt="[%X]",
handlers=[app_handler]
)
def setup_logging():
"""Setup or reset logging configuration with current settings."""
global app_handler, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, app_logger
# Get the root logger
root_logger = logging.getLogger()
# Remove existing handler if present
if app_handler is not None:
root_logger.removeHandler(app_handler)
try:
app_handler.close()
except:
pass
# Check if log file needs immediate rotation
if os.path.exists(log_file):
current_size = os.path.getsize(log_file)
max_bytes = LOG_MAX_SIZE_MB * 1024 * 1024
if current_size >= max_bytes:
# Perform immediate rotation
import shutil
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
backup_file = f"{log_file}.{timestamp}"
try:
shutil.move(str(log_file), backup_file)
except Exception as e:
print(f"Warning: Could not rotate log file: {e}")
# Clean up old backups if exceeding limit
log_dir = os.path.dirname(log_file)
log_basename = os.path.basename(log_file)
backup_pattern = f"{log_basename}.*"
import glob
backups = sorted(glob.glob(os.path.join(log_dir, backup_pattern)))
# Keep only the most recent backups
while len(backups) > LOG_BACKUP_COUNT:
oldest = backups.pop(0)
try:
os.remove(oldest)
except:
pass
# Create new handler with current settings
app_handler = RotatingRichHandler(
filename=str(log_file),
maxBytes=LOG_MAX_SIZE_MB * 1024 * 1024,
backupCount=LOG_BACKUP_COUNT,
encoding='utf-8'
)
# Set handler level to NOTSET so it processes all records
app_handler.setLevel(logging.NOTSET)
# Configure root logger - set to WARNING to suppress third-party library noise
root_logger.setLevel(logging.WARNING)
root_logger.addHandler(app_handler)
# Suppress noisy third-party loggers
# These libraries create DEBUG logs that pollute our log file
logging.getLogger('asyncio').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('openai').setLevel(logging.WARNING)
logging.getLogger('openrouter').setLevel(logging.WARNING)
# Get or create app logger and set its level (this filters what gets logged)
app_logger = logging.getLogger("oai_app")
app_logger.setLevel(LOG_LEVEL)
# Don't propagate to avoid root logger filtering
app_logger.propagate = True
return app_logger
app_logger = logging.getLogger("oai_app")
app_logger.setLevel(logging.INFO)
# Initial logging setup
app_logger = setup_logging()
def set_log_level(level_str: str) -> bool:
"""Set the application log level. Returns True if successful."""
global LOG_LEVEL, LOG_LEVEL_STR, app_logger
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
# Update the logger level immediately
if app_logger:
app_logger.setLevel(LOG_LEVEL)
return True
def reload_logging_config():
"""Reload logging configuration from database and reinitialize handler."""
global LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT, LOG_LEVEL, LOG_LEVEL_STR, app_logger
# Reload from database
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)
# Reinitialize logging
app_logger = setup_logging()
return app_logger
# ============================================================================
# END OF LOGGING SETUP
@@ -408,7 +733,64 @@ def export_as_html(session_history: List[Dict[str, str]], session_system_prompt:
return "\n".join(html_parts)
# Load configs
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 (AFTER logging is set up)
API_KEY = get_config('api_key')
OPENROUTER_BASE_URL = get_config('base_url') or "https://openrouter.ai/api/v1"
STREAM_ENABLED = get_config('stream_enabled') or "on"
@@ -618,7 +1000,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, app_logger
session_max_token = 0
session_system_prompt = ""
session_history = []
@@ -1028,9 +1410,13 @@ def chat():
console.print("[bold yellow]Usage: /middleout on|off (or /middleout to view status)[/]")
continue
elif user_input.lower() == "/reset":
confirm = typer.confirm("Reset conversation context? This clears history and prompt.", default=False)
if not confirm:
console.print("[bold yellow]Reset cancelled.[/]")
try:
confirm = typer.confirm("Reset conversation context? This clears history and prompt.", default=False)
if not confirm:
console.print("[bold yellow]Reset cancelled.[/]")
continue
except (EOFError, KeyboardInterrupt):
console.print("\n[bold yellow]Reset cancelled.[/]")
continue
session_history = []
current_index = -1
@@ -1213,11 +1599,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:
@@ -1230,9 +1633,13 @@ def chat():
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)")
# Reload logging configuration immediately
app_logger = reload_logging_config()
console.print(f"[bold green]Log size limit set to {new_size_mb} MB and applied immediately.[/]")
console.print(f"[dim cyan]Log file rotated if it exceeded the new limit.[/]")
app_logger.info(f"Log size limit updated to {new_size_mb} MB and reloaded")
except ValueError:
console.print("[bold red]Invalid size. Provide a number in MB.[/]")
elif args.startswith("online"):
@@ -1315,6 +1722,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 +1774,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 +1797,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]",
@@ -1461,9 +1877,14 @@ def chat():
)
help_table.add_row(
"/config log [size_mb]",
"Set log file size limit in MB. Older logs are rotated automatically. Requires restart.",
"Set log file size limit in MB. Older logs are rotated automatically. Takes effect immediately.",
"/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 +2020,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 <command> for detailed help • Memory ON by default • Use // to escape / • Visit: https://iurl.no/oai",
subtitle_align="center",
border_style="cyan"
))
@@ -1608,8 +2029,7 @@ def chat():
if not selected_model:
console.print("[bold yellow]Select a model first with '/model'.[/]")
continue
# Process file attachments with PDF support
# Process file attachments with PDF support
content_blocks = []
text_part = user_input
file_attachments = []