diff --git a/README.md b/README.md index 22945259..a8da77ed 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,6 @@ OpenBrowser is a framework for intelligent browser automation. It combines direc ## Installation -### Homebrew (macOS / Linux) - -```bash -brew tap billy-enrizky/openbrowser -brew install openbrowser-ai -``` - ### Quick install (macOS / Linux) ```bash @@ -75,11 +68,10 @@ curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/i ### Quick install (Windows PowerShell) ```powershell -# Windows (PowerShell) irm https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.ps1 | iex ``` -Detects `uv`, `pipx`, or `pip` and installs OpenBrowser + Chromium automatically. +Detects `uv`, `pipx`, or `pip` and installs OpenBrowser automatically. Install to `~/.local/bin` without sudo: @@ -87,6 +79,13 @@ Install to `~/.local/bin` without sudo: curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh -s -- --local ``` +### Homebrew (macOS / Linux) + +```bash +brew tap billy-enrizky/openbrowser +brew install openbrowser-ai +``` + ### pip ```bash @@ -138,13 +137,7 @@ pip install openbrowser-ai[azure] # Azure OpenAI pip install openbrowser-ai[video] # Video recording support ``` -### Install Browser - -```bash -uvx openbrowser-ai install -# or -playwright install chromium -``` +> **No separate browser install needed.** OpenBrowser auto-detects any installed Chromium-based browser (Chrome, Edge, Brave, Chromium) and uses it directly. If none is found and `uvx` is available, Chromium is installed automatically on first run. To pre-install manually (requires `uvx`): `openbrowser-ai install` ## Quick Start diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index b34a2157..7e4ef67b 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -9,24 +9,20 @@ To get started with OpenBrowser you need to install the package and set up an AP ## 1. Installing OpenBrowser - + **macOS / Linux** ```bash - brew tap billy-enrizky/openbrowser - brew install openbrowser-ai + curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh ``` - Installs OpenBrowser and Chromium into a Homebrew-managed virtualenv. - - - **macOS / Linux** + **Windows (PowerShell)** - ```bash - curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh + ```powershell + irm https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.ps1 | iex ``` - Detects `uv`, `pipx`, or `pip` and installs OpenBrowser + Chromium automatically. + Detects `uv`, `pipx`, or `pip` and installs OpenBrowser automatically. Install to `~/.local/bin` without sudo: @@ -34,6 +30,16 @@ To get started with OpenBrowser you need to install the package and set up an AP curl -fsSL https://raw.githubusercontent.com/billy-enrizky/openbrowser-ai/main/install.sh | sh -s -- --local ``` + + **macOS / Linux** + + ```bash + brew tap billy-enrizky/openbrowser + brew install openbrowser-ai + ``` + + Installs OpenBrowser into a Homebrew-managed virtualenv. + ```bash pip install openbrowser-ai @@ -87,15 +93,9 @@ To get started with OpenBrowser you need to install the package and set up an AP -### Install Browser - -After installing, set up Chromium: - -```bash -uvx openbrowser-ai install -# or -playwright install chromium -``` + +**No separate browser install needed.** OpenBrowser auto-detects any installed Chromium-based browser (Chrome, Edge, Brave, Chromium) and uses it directly. If none is found and `uvx` is available, Chromium is installed automatically on first run. To pre-install manually (requires `uvx`): `openbrowser-ai install` + ## 2. Choose your favorite LLM Create a `.env` file and add your API key. diff --git a/install.ps1 b/install.ps1 index 5d3eaabc..2403d41b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -161,22 +161,14 @@ if (-not $SkipBrowser) { } catch { try { - $null = Get-Command 'playwright' -ErrorAction Stop - & playwright install chromium 2>$null + $null = Get-Command 'uvx' -ErrorAction Stop + & uvx playwright install chromium 2>$null if ($LASTEXITCODE -ne 0) { - Write-Warn "Chromium install failed (run 'playwright install chromium' manually)" + Write-Warn "Chromium install failed (run 'openbrowser-ai install' manually)" } } catch { - try { - Invoke-Python -Arguments @('-m', 'playwright', 'install', 'chromium') 2>$null - if ($LASTEXITCODE -ne 0) { - Write-Warn "Chromium install skipped (run 'openbrowser-ai install' manually)" - } - } - catch { - Write-Warn "Chromium install skipped (run 'openbrowser-ai install' manually)" - } + Write-Warn "Chromium install skipped. Please run 'openbrowser-ai install' manually after installation completes." } } } diff --git a/install.sh b/install.sh index f63bfe2b..e6e63bcf 100755 --- a/install.sh +++ b/install.sh @@ -157,10 +157,10 @@ if [ "$SKIP_BROWSER" = false ]; then info "Installing Chromium browser..." if command -v openbrowser-ai >/dev/null 2>&1; then openbrowser-ai install 2>/dev/null || warn "Chromium install failed (run 'openbrowser-ai install' manually)" - elif command -v playwright >/dev/null 2>&1; then - playwright install chromium 2>/dev/null || warn "Chromium install failed (run 'playwright install chromium' manually)" + elif command -v uvx >/dev/null 2>&1; then + uvx playwright install chromium 2>/dev/null || warn "Chromium install failed (run 'openbrowser-ai install' manually)" else - "$PYTHON" -m playwright install chromium 2>/dev/null || warn "Chromium install skipped (run 'openbrowser-ai install' manually)" + warn "Chromium install skipped. Please run 'openbrowser-ai install' manually after installation completes." fi fi diff --git a/pyproject.toml b/pyproject.toml index 274caa78..6ad7c261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ # Core browser automation "pydantic>=2.0.0", "pydantic-settings>=2.0.0", - "playwright", "python-dotenv", "httpx>=0.28.1", "websockets>=15.0.1", @@ -40,7 +39,6 @@ agent = [ "boto3>=1.36.0", "reportlab", ] -cli = ["textual>=3.2.0"] video = ["imageio[ffmpeg]", "numpy"] anthropic = ["anthropic>=0.68.0"] groq = ["groq>=0.30.0"] @@ -57,7 +55,7 @@ dev = [ "mcp>=1.0.0", ] all = [ - "openbrowser-ai[agent,cli,video,anthropic,groq,ollama,aws,azure,pdf]", + "openbrowser-ai[agent,video,anthropic,groq,ollama,aws,azure,pdf]", ] [project.scripts] diff --git a/src/openbrowser/cli.py b/src/openbrowser/cli.py index 86805fec..39ed4409 100644 --- a/src/openbrowser/cli.py +++ b/src/openbrowser/cli.py @@ -13,7 +13,7 @@ logging.disable(logging.CRITICAL) # Early exit: run MCP server directly without loading heavy CLI dependencies - # (anthropic, openai, textual, etc. are not needed for MCP server mode) + # (anthropic, openai, etc. are not needed for MCP server mode) try: from openbrowser.telemetry import CLITelemetryEvent, ProductTelemetry from openbrowser.utils import get_openbrowser_version @@ -57,14 +57,24 @@ from openbrowser.daemon.client import DaemonClient client = DaemonClient() - status = asyncio.run(client.status()) - if status.success: + try: + status = asyncio.run(client.status()) + except TimeoutError: + # Daemon is alive but slow to respond -- show compact help + print(EXECUTE_CODE_DESCRIPTION_COMPACT) + sys.exit(0) + except (OSError, asyncio.CancelledError, ValueError): + status = None + if status and status.success: # Daemon already running (warm) -- compact description print(EXECUTE_CODE_DESCRIPTION_COMPACT) else: # Daemon not running (cold) -- verbose description, then start it print(EXECUTE_CODE_DESCRIPTION) - asyncio.run(client._start_daemon()) + try: + asyncio.run(client._start_daemon()) + except (TimeoutError, OSError, asyncio.CancelledError): + pass # Best-effort; daemon may still be starting in background sys.exit(0) from openbrowser.daemon.client import execute_code_via_daemon @@ -164,7 +174,7 @@ async def _restart(): sys.exit(1) sys.exit(0) -# Check for init subcommand early to avoid loading TUI dependencies +# Check for init subcommand early to avoid loading heavy dependencies if 'init' in sys.argv: from openbrowser.init_cmd import INIT_TEMPLATES from openbrowser.init_cmd import main as init_main @@ -190,7 +200,7 @@ async def _restart(): init_main() sys.exit(0) -# Check for --template flag early to avoid loading TUI dependencies +# Check for --template flag early to avoid loading heavy dependencies if '--template' in sys.argv: from pathlib import Path @@ -278,32 +288,18 @@ async def _restart(): load_dotenv() -from openbrowser import Agent, Controller -from openbrowser.agent.views import AgentSettings +try: + from openbrowser import Agent, Controller + from openbrowser.agent.views import AgentSettings +except ImportError: + Agent = None # type: ignore[assignment,misc] + Controller = None # type: ignore[assignment,misc] + AgentSettings = None # type: ignore[assignment,misc] from openbrowser.browser import BrowserProfile, BrowserSession -from openbrowser.logging_config import addLoggingLevel from openbrowser.telemetry import CLITelemetryEvent, ProductTelemetry from openbrowser.utils import get_openbrowser_version -try: - import click - from textual import events - from textual.app import App, ComposeResult - from textual.binding import Binding - from textual.containers import Container, HorizontalGroup, VerticalScroll - from textual.widgets import Footer, Header, Input, Label, Link, RichLog, Static -except ImportError: - print('CLI addon is not installed. Please install it with: `pip install "openbrowser-ai[cli]"` and try again.') - sys.exit(1) - - -try: - import readline - - READLINE_AVAILABLE = True -except ImportError: - # readline not available on Windows by default - READLINE_AVAILABLE = False +import click os.environ['OPENBROWSER_LOGGING_LEVEL'] = 'result' @@ -320,35 +316,6 @@ async def _restart(): # Default User settings MAX_HISTORY_LENGTH = 100 -# Directory setup will happen in functions that need CONFIG - - -# Logo components with styling for rich panels -BROWSER_LOGO = """ - [white] ++++++ +++++++++ [/] - [white] +++ +++++ +++ [/] - [white] ++ ++++ ++ ++ [/] - [white] ++ +++ +++ ++ [/] - [white] ++++ +++ [/] - [white] +++ +++ [/] - [white] +++ +++ [/] - [white] ++ +++ +++ ++ [/] - [white] ++ ++++ ++ ++ [/] - [white] +++ ++++++ +++ [/] - [white] ++++++ +++++++ [/] - -[darkorange] ██████╗ ██████╗ ███████╗███╗ ██╗[/][white]██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗███████╗██████╗[/] -[darkorange]██╔═══██╗██╔══██╗██╔════╝████╗ ██║[/][white]██╔══██╗██╔══██╗██╔═══██╗██║ ██║██╔════╝██╔════╝██╔══██╗[/] -[darkorange]██║ ██║██████╔╝█████╗ ██╔██╗ ██║[/][white]██████╔╝██████╔╝██║ ██║██║ █╗ ██║███████╗█████╗ ██████╔╝[/] -[darkorange]██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║[/][white]██╔══██╗██╔══██╗██║ ██║██║███╗██║╚════██║██╔══╝ ██╔══██╗[/] -[darkorange]╚██████╔╝██║ ███████╗██║ ╚████║[/][white]██████╔╝██║ ██║╚██████╔╝╚███╔███╔╝███████║███████╗██║ ██║[/] -[darkorange] ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝[/][white]╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝[/] -""" - - -# Common UI constants -TEXTUAL_BORDER_STYLES = {'logo': 'blue', 'info': 'blue', 'input': 'orange3', 'working': 'yellow', 'completion': 'green'} - def get_default_config() -> dict[str, Any]: """Return default configuration dictionary using the new config system.""" @@ -461,15 +428,6 @@ def update_config_with_click_args(config: dict[str, Any], ctx: click.Context) -> return config -def setup_readline_history(history: list[str]) -> None: - """Set up readline with command history.""" - if not READLINE_AVAILABLE: - return - - # Add history items to readline - for item in history: - readline.add_history(item) - def get_llm(config: dict[str, Any]): """Get the language model based on config and available API keys.""" @@ -530,1093 +488,7 @@ def get_llm(config: dict[str, Any]): sys.exit(1) -class RichLogHandler(logging.Handler): - """Custom logging handler that redirects logs to a RichLog widget.""" - - def __init__(self, rich_log: RichLog): - super().__init__() - self.rich_log = rich_log - - def emit(self, record): - try: - msg = self.format(record) - self.rich_log.write(msg) - except Exception: - self.handleError(record) - - -class OpenBrowserApp(App): - """OpenBrowser TUI application.""" - - # Make it an inline app instead of fullscreen - # MODES = {"light"} # Ensure app is inline, not fullscreen - - CSS = """ - #main-container { - height: 100%; - layout: vertical; - } - - #logo-panel, #links-panel, #paths-panel, #info-panels { - border: solid $primary; - margin: 0 0 0 0; - padding: 0; - } - - #info-panels { - display: none; - layout: vertical; - height: auto; - min-height: 5; - margin: 0 0 1 0; - } - - #top-panels { - layout: horizontal; - height: auto; - width: 100%; - } - - #browser-panel, #model-panel { - width: 1fr; - height: 100%; - padding: 1; - border-right: solid $primary; - } - - #model-panel { - border-right: none; - } - - #tasks-panel { - height: auto; - max-height: 10; - overflow-y: scroll; - padding: 1; - border-top: solid $primary; - } - - #browser-info, #model-info, #tasks-info { - height: auto; - margin: 0; - padding: 0; - background: transparent; - overflow-y: auto; - min-height: 3; - } - - #three-column-container { - height: 1fr; - layout: horizontal; - width: 100%; - display: none; - } - - #main-output-column { - width: 1fr; - height: 100%; - border: solid $primary; - padding: 0; - margin: 0 1 0 0; - } - - #events-column { - width: 1fr; - height: 100%; - border: solid $warning; - padding: 0; - margin: 0 1 0 0; - } - - #cdp-column { - width: 1fr; - height: 100%; - border: solid $accent; - padding: 0; - margin: 0; - } - - #main-output-log, #events-log, #cdp-log { - height: 100%; - overflow-y: scroll; - background: $surface; - color: $text; - width: 100%; - padding: 1; - } - - #events-log { - color: $warning; - } - - #cdp-log { - color: $accent-lighten-2; - } - - #logo-panel { - width: 100%; - height: auto; - content-align: center middle; - text-align: center; - } - - #links-panel { - width: 100%; - padding: 1; - border: solid $primary; - height: auto; - } - - .link-white { - color: white; - } - - .link-purple { - color: purple; - } - - .link-magenta { - color: magenta; - } - - .link-green { - color: green; - } - - HorizontalGroup { - height: auto; - } - - .link-label { - width: auto; - } - - .link-url { - width: auto; - } - - .link-row { - width: 100%; - height: auto; - } - - #paths-panel { - color: $text-muted; - } - - #task-input-container { - border: solid $accent; - padding: 1; - margin-bottom: 1; - height: auto; - dock: bottom; - } - - #task-label { - color: $accent; - padding-bottom: 1; - } - - #task-input { - width: 100%; - } - """ - - BINDINGS = [ - Binding('ctrl+c', 'quit', 'Quit', priority=True, show=True), - Binding('ctrl+q', 'quit', 'Quit', priority=True), - Binding('ctrl+d', 'quit', 'Quit', priority=True), - Binding('up', 'input_history_prev', 'Previous command', show=False), - Binding('down', 'input_history_next', 'Next command', show=False), - ] - - def __init__(self, config: dict[str, Any], *args, **kwargs): - super().__init__(*args, **kwargs) - self.config = config - self.browser_session: BrowserSession | None = None # Will be set before app.run_async() - self.controller: Controller | None = None # Will be set before app.run_async() - self.agent: Agent | None = None - self.llm: Any | None = None # Will be set before app.run_async() - self.task_history = config.get('command_history', []) - # Track current position in history for up/down navigation - self.history_index = len(self.task_history) - # Initialize telemetry - self._telemetry = ProductTelemetry() - # Store for event bus handler - self._event_bus_handler_id = None - self._event_bus_handler_func = None - # Timer for info panel updates - self._info_panel_timer = None - - def setup_richlog_logging(self) -> None: - """Set up logging to redirect to RichLog widget instead of stdout.""" - # Try to add RESULT level if it doesn't exist - try: - addLoggingLevel('RESULT', 35) - except AttributeError: - pass # Level already exists, which is fine - - # Get the main output RichLog widget - rich_log = self.query_one('#main-output-log', RichLog) - - # Create and set up the custom handler - log_handler = RichLogHandler(rich_log) - log_type = os.getenv('OPENBROWSER_LOGGING_LEVEL', 'result').lower() - - class OpenBrowserFormatter(logging.Formatter): - def format(self, record): - # if isinstance(record.name, str) and record.name.startswith('openbrowser.'): - # record.name = record.name.split('.')[-2] - return super().format(record) - - # Set up the formatter based on log type - if log_type == 'result': - log_handler.setLevel('RESULT') - log_handler.setFormatter(OpenBrowserFormatter('%(message)s')) - else: - log_handler.setFormatter(OpenBrowserFormatter('%(levelname)-8s [%(name)s] %(message)s')) - - # Configure root logger - Replace ALL handlers, not just stdout handlers - root = logging.getLogger() - - # Clear all existing handlers to prevent output to stdout/stderr - root.handlers = [] - root.addHandler(log_handler) - - # Set log level based on environment variable - if log_type == 'result': - root.setLevel('RESULT') - elif log_type == 'debug': - root.setLevel(logging.DEBUG) - else: - root.setLevel(logging.INFO) - - # Configure openbrowser logger and all its sub-loggers - openbrowser_logger = logging.getLogger('openbrowser') - openbrowser_logger.propagate = False # Don't propagate to root logger - openbrowser_logger.handlers = [log_handler] # Replace any existing handlers - openbrowser_logger.setLevel(root.level) - - # Also ensure agent loggers go to the main output - # Use a wildcard pattern to catch all agent-related loggers - for logger_name in ['openbrowser.Agent', 'openbrowser.controller', 'openbrowser.agent', 'openbrowser.agent.service']: - agent_logger = logging.getLogger(logger_name) - agent_logger.propagate = False - agent_logger.handlers = [log_handler] - agent_logger.setLevel(root.level) - - # Also catch any dynamically created agent loggers with task IDs - for name, logger in logging.Logger.manager.loggerDict.items(): - if isinstance(name, str) and 'openbrowser.Agent' in name: - if isinstance(logger, logging.Logger): - logger.propagate = False - logger.handlers = [log_handler] - logger.setLevel(root.level) - - # Silence third-party loggers but keep them using our handler - for logger_name in [ - 'WDM', - 'httpx', - 'selenium', - 'playwright', - 'urllib3', - 'asyncio', - 'openai', - 'httpcore', - 'charset_normalizer', - 'anthropic._base_client', - 'PIL.PngImagePlugin', - 'trafilatura.htmlprocessing', - 'trafilatura', - 'groq', - 'portalocker', - 'portalocker.utils', - ]: - third_party = logging.getLogger(logger_name) - third_party.setLevel(logging.ERROR) - third_party.propagate = False - third_party.handlers = [log_handler] # Use our handler to prevent stdout/stderr leakage - - def on_mount(self) -> None: - """Set up components when app is mounted.""" - # We'll use a file logger since stdout is now controlled by Textual - logger = logging.getLogger('openbrowser.on_mount') - logger.debug('on_mount() method started') - - # Step 1: Set up custom logging to RichLog - logger.debug('Setting up RichLog logging...') - try: - self.setup_richlog_logging() - logger.debug('RichLog logging set up successfully') - except Exception as e: - logger.error(f'Error setting up RichLog logging: {str(e)}', exc_info=True) - raise RuntimeError(f'Failed to set up RichLog logging: {str(e)}') - - # Step 2: Set up input history - logger.debug('Setting up readline history...') - try: - if READLINE_AVAILABLE and self.task_history: - for item in self.task_history: - readline.add_history(item) - logger.debug(f'Added {len(self.task_history)} items to readline history') - else: - logger.debug('No readline history to set up') - except Exception as e: - logger.error(f'Error setting up readline history: {str(e)}', exc_info=False) - # Non-critical, continue - - # Step 3: Focus the input field - logger.debug('Focusing input field...') - try: - input_field = self.query_one('#task-input', Input) - input_field.focus() - logger.debug('Input field focused') - except Exception as e: - logger.error(f'Error focusing input field: {str(e)}', exc_info=True) - # Non-critical, continue - - # Step 5: Setup CDP logger and event bus listener if browser session is available - logger.debug('Setting up CDP logging and event bus listener...') - try: - self.setup_cdp_logger() - if self.browser_session: - self.setup_event_bus_listener() - logger.debug('CDP logging and event bus setup complete') - except Exception as e: - logger.error(f'Error setting up CDP logging/event bus: {str(e)}', exc_info=True) - # Non-critical, continue - - # Capture telemetry for CLI start - self._telemetry.capture( - CLITelemetryEvent( - version=get_openbrowser_version(), - action='start', - mode='interactive', - model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None, - model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None, - ) - ) - - logger.debug('on_mount() completed successfully') - - def on_input_key_up(self, event: events.Key) -> None: - """Handle up arrow key in the input field.""" - # For textual key events, we need to check focus manually - input_field = self.query_one('#task-input', Input) - if not input_field.has_focus: - return - - # Only process if we have history - if not self.task_history: - return - - # Move back in history if possible - if self.history_index > 0: - self.history_index -= 1 - task_input = self.query_one('#task-input', Input) - task_input.value = self.task_history[self.history_index] - # Move cursor to end of text - task_input.cursor_position = len(task_input.value) - - # Prevent default behavior (cursor movement) - event.prevent_default() - event.stop() - - def on_input_key_down(self, event: events.Key) -> None: - """Handle down arrow key in the input field.""" - # For textual key events, we need to check focus manually - input_field = self.query_one('#task-input', Input) - if not input_field.has_focus: - return - - # Only process if we have history - if not self.task_history: - return - - # Move forward in history or clear input if at the end - if self.history_index < len(self.task_history) - 1: - self.history_index += 1 - task_input = self.query_one('#task-input', Input) - task_input.value = self.task_history[self.history_index] - # Move cursor to end of text - task_input.cursor_position = len(task_input.value) - elif self.history_index == len(self.task_history) - 1: - # At the end of history, go to "new line" state - self.history_index += 1 - self.query_one('#task-input', Input).value = '' - - # Prevent default behavior (cursor movement) - event.prevent_default() - event.stop() - - async def on_key(self, event: events.Key) -> None: - """Handle key events at the app level to ensure graceful exit.""" - # Handle Ctrl+C, Ctrl+D, and Ctrl+Q for app exit - if event.key == 'ctrl+c' or event.key == 'ctrl+d' or event.key == 'ctrl+q': - await self.action_quit() - event.stop() - event.prevent_default() - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle task input submission.""" - if event.input.id == 'task-input': - task = event.input.value - if not task.strip(): - return - - # Add to history if it's new - if task.strip() and (not self.task_history or task != self.task_history[-1]): - self.task_history.append(task) - self.config['command_history'] = self.task_history - save_user_config(self.config) - - # Reset history index to point past the end of history - self.history_index = len(self.task_history) - - # Hide logo, links, and paths panels - self.hide_intro_panels() - - # Process the task - self.run_task(task) - - # Clear the input - event.input.value = '' - - def hide_intro_panels(self) -> None: - """Hide the intro panels, show info panels and the three-column view.""" - try: - # Get the panels - logo_panel = self.query_one('#logo-panel') - links_panel = self.query_one('#links-panel') - paths_panel = self.query_one('#paths-panel') - info_panels = self.query_one('#info-panels') - three_column = self.query_one('#three-column-container') - - # Hide intro panels if they're visible and show info panels + three-column view - if logo_panel.display: - logging.debug('Hiding intro panels and showing info panels + three-column view') - - logo_panel.display = False - links_panel.display = False - paths_panel.display = False - - # Show info panels and three-column container - info_panels.display = True - three_column.display = True - - # Start updating info panels - self.update_info_panels() - - logging.debug('Info panels and three-column view should now be visible') - except Exception as e: - logging.error(f'Error in hide_intro_panels: {str(e)}') - - def setup_event_bus_listener(self) -> None: - """Setup listener for browser session event bus.""" - if not self.browser_session or not self.browser_session.event_bus: - return - - # Clean up any existing handler before registering a new one - if self._event_bus_handler_func is not None: - try: - # Remove handler from the event bus's internal handlers dict - if hasattr(self.browser_session.event_bus, 'handlers'): - # Find and remove our handler function from all event patterns - for event_type, handler_list in list(self.browser_session.event_bus.handlers.items()): - # Remove our specific handler function object - if self._event_bus_handler_func in handler_list: - handler_list.remove(self._event_bus_handler_func) - logging.debug(f'Removed old handler from event type: {event_type}') - except Exception as e: - logging.debug(f'Error cleaning up event bus handler: {e}') - self._event_bus_handler_func = None - self._event_bus_handler_id = None - - try: - # Get the events log widget - events_log = self.query_one('#events-log', RichLog) - except Exception: - # Widget not ready yet - return - - # Create handler to log all events - def log_event(event): - event_name = event.__class__.__name__ - # Format event data nicely - try: - if hasattr(event, 'model_dump'): - event_data = event.model_dump(exclude_unset=True) - # Remove large fields - if 'screenshot' in event_data: - event_data['screenshot'] = '' - if 'dom_state' in event_data: - event_data['dom_state'] = '' - event_str = str(event_data) if event_data else '' - else: - event_str = str(event) - - # Truncate long strings - if len(event_str) > 200: - event_str = event_str[:200] + '...' - - events_log.write(f'[yellow]→ {event_name}[/] {event_str}') - except Exception as e: - events_log.write(f'[red]→ {event_name}[/] (error formatting: {e})') - - # Store the handler function before registering it - self._event_bus_handler_func = log_event - self._event_bus_handler_id = id(log_event) - - # Register wildcard handler for all events - self.browser_session.event_bus.on('*', log_event) - logging.debug(f'Registered new event bus handler with id: {self._event_bus_handler_id}') - - def setup_cdp_logger(self) -> None: - """Setup CDP message logger to capture already-transformed CDP logs.""" - # No need to configure levels - setup_logging() already handles that - # We just need to capture the transformed logs and route them to the CDP pane - - # Get the CDP log widget - cdp_log = self.query_one('#cdp-log', RichLog) - - # Create custom handler for CDP logging - class CDPLogHandler(logging.Handler): - def __init__(self, rich_log: RichLog): - super().__init__() - self.rich_log = rich_log - - def emit(self, record): - try: - msg = self.format(record) - # Truncate very long messages - if len(msg) > 300: - msg = msg[:300] + '...' - # Color code by level - if record.levelno >= logging.ERROR: - self.rich_log.write(f'[red]{msg}[/]') - elif record.levelno >= logging.WARNING: - self.rich_log.write(f'[yellow]{msg}[/]') - else: - self.rich_log.write(f'[cyan]{msg}[/]') - except Exception: - self.handleError(record) - - # Setup handler for cdp_use loggers - cdp_handler = CDPLogHandler(cdp_log) - cdp_handler.setFormatter(logging.Formatter('%(message)s')) - cdp_handler.setLevel(logging.DEBUG) - - # Route CDP logs to the CDP pane - # These are already transformed by cdp_use and at the right level from setup_logging - for logger_name in ['websockets.client', 'cdp_use', 'cdp_use.client', 'cdp_use.cdp', 'cdp_use.cdp.registry']: - logger = logging.getLogger(logger_name) - # Add our handler (don't replace - keep existing console handler too) - if cdp_handler not in logger.handlers: - logger.addHandler(cdp_handler) - - def scroll_to_input(self) -> None: - """Scroll to the input field to ensure it's visible.""" - input_container = self.query_one('#task-input-container') - input_container.scroll_visible() - - def run_task(self, task: str) -> None: - """Launch the task in a background worker.""" - # Create or update the agent - agent_settings = AgentSettings.model_validate(self.config.get('agent', {})) - - # Get the logger - logger = logging.getLogger('openbrowser.app') - - # Make sure intro is hidden and log is ready - self.hide_intro_panels() - - # Clear the main output log to start fresh - rich_log = self.query_one('#main-output-log', RichLog) - rich_log.clear() - - if self.agent is None: - if not self.llm: - raise RuntimeError('LLM not initialized') - self.agent = Agent( - task=task, - llm=self.llm, - controller=self.controller if self.controller else Controller(), - browser_session=self.browser_session, - source='cli', - **agent_settings.model_dump(), - ) - # Update our browser_session reference to point to the agent's - if hasattr(self.agent, 'browser_session'): - self.browser_session = self.agent.browser_session - # Set up event bus listener (will clean up any old handler first) - self.setup_event_bus_listener() - else: - self.agent.add_new_task(task) - - # Let the agent run in the background - async def agent_task_worker() -> None: - logger.debug('\n🚀 Working on task: %s', task) - - # Set flags to indicate the agent is running - if self.agent: - self.agent.running = True # type: ignore - self.agent.last_response_time = 0 # type: ignore - - # Panel updates are already happening via the timer in update_info_panels - - task_start_time = time.time() - error_msg = None - - try: - # Capture telemetry for message sent - self._telemetry.capture( - CLITelemetryEvent( - version=get_openbrowser_version(), - action='message_sent', - mode='interactive', - model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None, - model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None, - ) - ) - - # Run the agent task, redirecting output to RichLog through our handler - if self.agent: - await self.agent.run() - except Exception as e: - error_msg = str(e) - logger.error('\nError running agent: %s', str(e)) - finally: - # Clear the running flag - if self.agent: - self.agent.running = False # type: ignore - - # Capture telemetry for task completion - duration = time.time() - task_start_time - self._telemetry.capture( - CLITelemetryEvent( - version=get_openbrowser_version(), - action='task_completed' if error_msg is None else 'error', - mode='interactive', - model=self.llm.model if self.llm and hasattr(self.llm, 'model') else None, - model_provider=self.llm.provider if self.llm and hasattr(self.llm, 'provider') else None, - duration_seconds=duration, - error_message=error_msg, - ) - ) - - logger.debug('\n✅ Task completed!') - - # Make sure the task input container is visible - task_input_container = self.query_one('#task-input-container') - task_input_container.display = True - - # Refocus the input field - input_field = self.query_one('#task-input', Input) - input_field.focus() - - # Ensure the input is visible by scrolling to it - self.call_after_refresh(self.scroll_to_input) - - # Run the worker - self.run_worker(agent_task_worker, name='agent_task') - - def action_input_history_prev(self) -> None: - """Navigate to the previous item in command history.""" - # Only process if we have history and input is focused - input_field = self.query_one('#task-input', Input) - if not input_field.has_focus or not self.task_history: - return - - # Move back in history if possible - if self.history_index > 0: - self.history_index -= 1 - input_field.value = self.task_history[self.history_index] - # Move cursor to end of text - input_field.cursor_position = len(input_field.value) - - def action_input_history_next(self) -> None: - """Navigate to the next item in command history or clear input.""" - # Only process if we have history and input is focused - input_field = self.query_one('#task-input', Input) - if not input_field.has_focus or not self.task_history: - return - - # Move forward in history or clear input if at the end - if self.history_index < len(self.task_history) - 1: - self.history_index += 1 - input_field.value = self.task_history[self.history_index] - # Move cursor to end of text - input_field.cursor_position = len(input_field.value) - elif self.history_index == len(self.task_history) - 1: - # At the end of history, go to "new line" state - self.history_index += 1 - input_field.value = '' - - async def action_quit(self) -> None: - """Quit the application and clean up resources.""" - # Note: We don't need to close the browser session here because: - # 1. If an agent exists, it already called browser_session.stop() in its run() method - # 2. If keep_alive=True (default), we want to leave the browser running anyway - # This prevents the duplicate "stop() called" messages in the logs - - # Flush telemetry before exiting - self._telemetry.flush() - - # Exit the application - self.exit() - print('\nTry running tasks at: https://github.com/billy-enrizky/openbrowser-ai') - - def compose(self) -> ComposeResult: - """Create the UI layout.""" - yield Header() - - # Main container for app content - with Container(id='main-container'): - # Logo panel - yield Static(BROWSER_LOGO, id='logo-panel', markup=True) - - # Links panel with URLs - with Container(id='links-panel'): - with HorizontalGroup(classes='link-row'): - yield Static('GitHub Repository: ', markup=True, classes='link-label') - yield Link('https://github.com/billy-enrizky/openbrowser-ai', url='https://github.com/billy-enrizky/openbrowser-ai', classes='link-white link-url') - - yield Static('') # Empty line - - with HorizontalGroup(classes='link-row'): - yield Static('Chat & share on Discord: ', markup=True, classes='link-label') - yield Link( - 'https://discord.gg/YRXzbJjq9K', url='https://discord.gg/YRXzbJjq9K', classes='link-purple link-url' - ) - - with HorizontalGroup(classes='link-row'): - yield Static('Get prompt inspiration: 🦸 ', markup=True, classes='link-label') - yield Link( - 'https://github.com/billy-enrizky/openbrowser-ai', - url='https://github.com/billy-enrizky/openbrowser-ai', - classes='link-magenta link-url', - ) - - with HorizontalGroup(classes='link-row'): - yield Static('[dim]Report any issues:[/] 🐛 ', markup=True, classes='link-label') - yield Link( - 'https://github.com/billy-enrizky/openbrowser-ai/issues', - url='https://github.com/billy-enrizky/openbrowser-ai/issues', - classes='link-green link-url', - ) - - # Paths panel - yield Static( - f' ⚙️ Settings saved to: {str(CONFIG.OPENBROWSER_CONFIG_FILE.resolve()).replace(str(Path.home()), "~")}\n' - f' 📁 Outputs & recordings saved to: {str(Path(".").resolve()).replace(str(Path.home()), "~")}', - id='paths-panel', - markup=True, - ) - - # Info panels (hidden by default, shown when task starts) - with Container(id='info-panels'): - # Top row with browser and model panels side by side - with Container(id='top-panels'): - # Browser panel - with Container(id='browser-panel'): - yield RichLog(id='browser-info', markup=True, highlight=True, wrap=True) - - # Model panel - with Container(id='model-panel'): - yield RichLog(id='model-info', markup=True, highlight=True, wrap=True) - - # Tasks panel (full width, below browser and model) - with VerticalScroll(id='tasks-panel'): - yield RichLog(id='tasks-info', markup=True, highlight=True, wrap=True, auto_scroll=True) - - # Three-column container (hidden by default) - with Container(id='three-column-container'): - # Column 1: Main output - with VerticalScroll(id='main-output-column'): - yield RichLog(highlight=True, markup=True, id='main-output-log', wrap=True, auto_scroll=True) - - # Column 2: Event bus events - with VerticalScroll(id='events-column'): - yield RichLog(highlight=True, markup=True, id='events-log', wrap=True, auto_scroll=True) - - # Column 3: CDP messages - with VerticalScroll(id='cdp-column'): - yield RichLog(highlight=True, markup=True, id='cdp-log', wrap=True, auto_scroll=True) - - # Task input container (now at the bottom) - with Container(id='task-input-container'): - yield Label('🔍 What would you like me to do on the web?', id='task-label') - yield Input(placeholder='Enter your task...', id='task-input') - - yield Footer() - - def update_info_panels(self) -> None: - """Update all information panels with current state.""" - try: - # Update actual content - self.update_browser_panel() - self.update_model_panel() - self.update_tasks_panel() - except Exception as e: - logging.error(f'Error in update_info_panels: {str(e)}') - finally: - # Always schedule the next update - will update at 1-second intervals - # This ensures continuous updates even if agent state changes - self.set_timer(1.0, self.update_info_panels) - - def update_browser_panel(self) -> None: - """Update browser information panel with details about the browser.""" - browser_info = self.query_one('#browser-info', RichLog) - browser_info.clear() - - # Try to use the agent's browser session if available - browser_session = self.browser_session - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'browser_session'): - browser_session = self.agent.browser_session - - if browser_session: - try: - # Check if browser session has a CDP client - if not hasattr(browser_session, 'cdp_client') or browser_session.cdp_client is None: - browser_info.write('[yellow]Browser session created, waiting for browser to launch...[/]') - return - - # Update our reference if we're using the agent's session - if browser_session != self.browser_session: - self.browser_session = browser_session - - # Get basic browser info from browser_profile - browser_type = 'Chromium' - headless = browser_session.browser_profile.headless - - # Determine connection type based on config - connection_type = 'playwright' # Default - if browser_session.cdp_url: - connection_type = 'CDP' - elif browser_session.browser_profile.executable_path: - connection_type = 'user-provided' - - # Get window size details from browser_profile - window_width = None - window_height = None - if browser_session.browser_profile.viewport: - window_width = browser_session.browser_profile.viewport.width - window_height = browser_session.browser_profile.viewport.height - - # Try to get browser PID - browser_pid = 'Unknown' - connected = False - browser_status = '[red]Disconnected[/]' - - try: - # Check if browser PID is available - # Check if we have a CDP client - if browser_session.cdp_client is not None: - connected = True - browser_status = '[green]Connected[/]' - browser_pid = 'N/A' - except Exception as e: - browser_pid = f'Error: {str(e)}' - - # Display browser information - browser_info.write(f'[bold cyan]Chromium[/] Browser ({browser_status})') - browser_info.write( - f'Type: [yellow]{connection_type}[/] [{"green" if not headless else "red"}]{" (headless)" if headless else ""}[/]' - ) - browser_info.write(f'PID: [dim]{browser_pid}[/]') - browser_info.write(f'CDP Port: {browser_session.cdp_url}') - - if window_width and window_height: - browser_info.write(f'Window: [blue]{window_width}[/] × [blue]{window_height}[/]') - - # Include additional information about the browser if needed - if connected and hasattr(self, 'agent') and self.agent: - try: - # Show when the browser was connected - timestamp = int(time.time()) - current_time = time.strftime('%H:%M:%S', time.localtime(timestamp)) - browser_info.write(f'Last updated: [dim]{current_time}[/]') - except Exception: - pass - - # Show the agent's current page URL if available - if browser_session.agent_focus: - current_url = ( - browser_session.agent_focus.url.replace('https://', '') - .replace('http://', '') - .replace('www.', '')[:36] - + '…' - ) - browser_info.write(f'👁️ [green]{current_url}[/]') - except Exception as e: - browser_info.write(f'[red]Error updating browser info: {str(e)}[/]') - else: - browser_info.write('[red]Browser not initialized[/]') - - def update_model_panel(self) -> None: - """Update model information panel with details about the LLM.""" - model_info = self.query_one('#model-info', RichLog) - model_info.clear() - - if self.llm: - # Get model details - model_name = 'Unknown' - if hasattr(self.llm, 'model_name'): - model_name = self.llm.model_name - elif hasattr(self.llm, 'model'): - model_name = self.llm.model - - # Show model name - if self.agent: - temp_str = f'{self.llm.temperature}ºC ' if self.llm.temperature else '' - vision_str = '+ vision ' if self.agent.settings.use_vision else '' - model_info.write( - f'[white]LLM:[/] [blue]{self.llm.__class__.__name__} [yellow]{model_name}[/] {temp_str}{vision_str}' - ) - else: - model_info.write(f'[white]LLM:[/] [blue]{self.llm.__class__.__name__} [yellow]{model_name}[/]') - - # Show token usage statistics if agent exists and has history - if self.agent and hasattr(self.agent, 'state') and hasattr(self.agent.state, 'history'): - # Calculate tokens per step - num_steps = len(self.agent.history.history) - - # Get the last step metadata to show the most recent LLM response time - if num_steps > 0 and self.agent.history.history[-1].metadata: - last_step = self.agent.history.history[-1] - if last_step.metadata: - step_duration = last_step.metadata.duration_seconds - else: - step_duration = 0 - - # Show total duration - total_duration = self.agent.history.total_duration_seconds() - if total_duration > 0: - model_info.write(f'[white]Total Duration:[/] [magenta]{total_duration:.2f}s[/]') - - # Calculate response time metrics - model_info.write(f'[white]Last Step Duration:[/] [magenta]{step_duration:.2f}s[/]') - - # Add current state information - if hasattr(self.agent, 'running'): - if getattr(self.agent, 'running', False): - model_info.write('[yellow]LLM is thinking[blink]...[/][/]') - elif hasattr(self.agent, 'state') and hasattr(self.agent.state, 'paused') and self.agent.state.paused: - model_info.write('[orange]LLM paused[/]') - else: - model_info.write('[red]Model not initialized[/]') - - def update_tasks_panel(self) -> None: - """Update tasks information panel with details about the tasks and steps hierarchy.""" - tasks_info = self.query_one('#tasks-info', RichLog) - tasks_info.clear() - - if self.agent: - # Check if agent has tasks - task_history = [] - message_history = [] - - # Try to extract tasks by looking at message history - if hasattr(self.agent, '_message_manager') and self.agent._message_manager: - message_history = self.agent._message_manager.state.history.get_messages() - - # Extract original task(s) - original_tasks = [] - for msg in message_history: - if hasattr(msg, 'content'): - content = msg.content - if isinstance(content, str) and 'Your ultimate task is:' in content: - task_text = content.split('"""')[1].strip() - original_tasks.append(task_text) - - if original_tasks: - tasks_info.write('[bold green]TASK:[/]') - for i, task in enumerate(original_tasks, 1): - # Only show latest task if multiple task changes occurred - if i == len(original_tasks): - tasks_info.write(f'[white]{task}[/]') - tasks_info.write('') - - # Get current state information - current_step = self.agent.state.n_steps if hasattr(self.agent, 'state') else 0 - - # Get all agent history items - history_items = [] - if hasattr(self.agent, 'state') and hasattr(self.agent.state, 'history'): - history_items = self.agent.history.history - - if history_items: - tasks_info.write('[bold yellow]STEPS:[/]') - - for idx, item in enumerate(history_items, 1): - # Determine step status - step_style = '[green]✓[/]' - - # For the current step, show it as in progress - if idx == current_step: - step_style = '[yellow]⟳[/]' - - # Check if this step had an error - if item.result and any(result.error for result in item.result): - step_style = '[red]✗[/]' - - # Show step number - tasks_info.write(f'{step_style} Step {idx}/{current_step}') - - # Show goal if available - if item.model_output and hasattr(item.model_output, 'current_state'): - # Show goal for this step - goal = item.model_output.current_state.next_goal - if goal: - # Take just the first line for display - goal_lines = goal.strip().split('\n') - goal_summary = goal_lines[0] - tasks_info.write(f' [cyan]Goal:[/] {goal_summary}') - - # Show evaluation of previous goal (feedback) - eval_prev = item.model_output.current_state.evaluation_previous_goal - if eval_prev and idx > 1: # Only show for steps after the first - eval_lines = eval_prev.strip().split('\n') - eval_summary = eval_lines[0] - eval_summary = eval_summary.replace('Success', '✅ ').replace('Failed', '❌ ').strip() - tasks_info.write(f' [tan]Evaluation:[/] {eval_summary}') - - # Show actions taken in this step - if item.model_output and item.model_output.action: - tasks_info.write(' [purple]Actions:[/]') - for action_idx, action in enumerate(item.model_output.action, 1): - action_type = action.__class__.__name__ - if hasattr(action, 'model_dump'): - # For proper actions, show the action type - action_dict = action.model_dump(exclude_unset=True) - if action_dict: - action_name = list(action_dict.keys())[0] - tasks_info.write(f' {action_idx}. [blue]{action_name}[/]') - - # Show results or errors from this step - if item.result: - for result in item.result: - if result.error: - error_text = result.error - tasks_info.write(f' [red]Error:[/] {error_text}') - elif result.extracted_content: - content = result.extracted_content - tasks_info.write(f' [green]Result:[/] {content}') - - # Add a space between steps for readability - tasks_info.write('') - - # If agent is actively running, show a status indicator - if hasattr(self.agent, 'running') and getattr(self.agent, 'running', False): - tasks_info.write('[yellow]Agent is actively working[blink]...[/][/]') - elif hasattr(self.agent, 'state') and hasattr(self.agent.state, 'paused') and self.agent.state.paused: - tasks_info.write('[orange]Agent is paused (press Enter to resume)[/]') - else: - tasks_info.write('[dim]Agent not initialized[/]') - # Force scroll to bottom - tasks_panel = self.query_one('#tasks-panel') - tasks_panel.scroll_end(animate=False) async def run_prompt_mode(prompt: str, ctx: click.Context, debug: bool = False): @@ -1742,122 +614,6 @@ async def run_prompt_mode(prompt: str, ctx: click.Context, debug: bool = False): await asyncio.gather(*tasks, return_exceptions=True) -async def textual_interface(config: dict[str, Any]): - """Run the Textual interface.""" - # Prevent openbrowser from setting up logging at import time - os.environ['OPENBROWSER_SETUP_LOGGING'] = 'false' - - logger = logging.getLogger('openbrowser.startup') - - # Set up logging for Textual UI - prevent any logging to stdout - def setup_textual_logging(): - # Replace all handlers with null handler - root_logger = logging.getLogger() - for handler in root_logger.handlers: - root_logger.removeHandler(handler) - - # Add null handler to ensure no output to stdout/stderr - null_handler = logging.NullHandler() - root_logger.addHandler(null_handler) - logger.debug('Logging configured for Textual UI') - - logger.debug('Setting up Browser, Controller, and LLM...') - - # Step 1: Initialize BrowserSession with config - logger.debug('Initializing BrowserSession...') - try: - # Get browser config from the config dict - browser_config = config.get('browser', {}) - - logger.info('Browser type: chromium') # BrowserSession only supports chromium - if browser_config.get('executable_path'): - logger.info(f'Browser binary: {browser_config["executable_path"]}') - if browser_config.get('headless'): - logger.info('Browser mode: headless') - else: - logger.info('Browser mode: visible') - - # Create BrowserSession directly with config parameters - # Remove None values from browser_config - browser_config = {k: v for k, v in browser_config.items() if v is not None} - # Create BrowserProfile with user_data_dir - profile = BrowserProfile(user_data_dir=str(USER_DATA_DIR), **browser_config) - browser_session = BrowserSession( - browser_profile=profile, - ) - logger.debug('BrowserSession initialized successfully') - - # Set up FIFO logging pipes for streaming logs to UI - try: - from openbrowser.logging_config import setup_log_pipes - - setup_log_pipes(session_id=browser_session.id) - logger.debug(f'FIFO logging pipes set up for session {browser_session.id[-4:]}') - except Exception as e: - logger.debug(f'Could not set up FIFO logging pipes: {e}') - - # Browser version logging not available with CDP implementation - except Exception as e: - logger.error(f'Error initializing BrowserSession: {str(e)}', exc_info=True) - raise RuntimeError(f'Failed to initialize BrowserSession: {str(e)}') - - # Step 3: Initialize Controller - logger.debug('Initializing Controller...') - try: - controller = Controller() - logger.debug('Controller initialized successfully') - except Exception as e: - logger.error(f'Error initializing Controller: {str(e)}', exc_info=True) - raise RuntimeError(f'Failed to initialize Controller: {str(e)}') - - # Step 4: Get LLM - logger.debug('Getting LLM...') - try: - # Ensure setup_logging is not called when importing modules - os.environ['OPENBROWSER_SETUP_LOGGING'] = 'false' - llm = get_llm(config) - # Log LLM details - model_name = getattr(llm, 'model_name', None) or getattr(llm, 'model', 'Unknown model') - provider = llm.__class__.__name__ - temperature = getattr(llm, 'temperature', 0.0) - logger.info(f'LLM: {provider} ({model_name}), temperature: {temperature}') - logger.debug(f'LLM initialized successfully: {provider}') - except Exception as e: - logger.error(f'Error getting LLM: {str(e)}', exc_info=True) - raise RuntimeError(f'Failed to initialize LLM: {str(e)}') - - logger.debug('Initializing OpenBrowserApp instance...') - try: - app = OpenBrowserApp(config) - # Pass the initialized components to the app - app.browser_session = browser_session - app.controller = controller - app.llm = llm - - # Set up event bus listener now that browser session is available - # Note: This needs to be called before run_async() but after browser_session is set - # We'll defer this to on_mount() since it needs the widgets to be available - - # Configure logging for Textual UI before going fullscreen - setup_textual_logging() - - # Log browser and model configuration that will be used - browser_type = 'Chromium' # BrowserSession only supports Chromium - model_name = config.get('model', {}).get('name', 'auto-detected') - headless = config.get('browser', {}).get('headless', False) - headless_str = 'headless' if headless else 'visible' - - logger.info(f'Preparing {browser_type} browser ({headless_str}) with {model_name} LLM') - - logger.debug('Starting Textual app with run_async()...') - # No more logging after this point as we're in fullscreen mode - await app.run_async() - except Exception as e: - logger.error(f'Error in textual_interface: {str(e)}', exc_info=True) - # Note: We don't close the browser session here to avoid duplicate stop() calls - # The browser session will be cleaned up by its __del__ method if needed - raise - @click.group(invoke_without_command=True) @click.option('--version', is_flag=True, help='Print version and exit') @@ -1882,17 +638,19 @@ def setup_textual_logging(): @click.option('--no-proxy', type=str, help='Comma-separated hosts to bypass proxy (e.g. localhost,127.0.0.1,*.internal)') @click.option('--proxy-username', type=str, help='Proxy auth username') @click.option('--proxy-password', type=str, help='Proxy auth password') -@click.option('-p', '--prompt', type=str, help='Run a single task without the TUI (headless mode)') +@click.option('-p', '--prompt', type=str, help='Run a single task in one-shot mode') @click.option('--mcp', is_flag=True, help='Run as MCP server (exposes JSON RPC via stdin/stdout)') @click.pass_context def main(ctx: click.Context, debug: bool = False, **kwargs): """OpenBrowser - AI Agent for Web Automation - Run without arguments to start the interactive TUI. - Examples: - uvx openbrowser-ai --template default - uvx openbrowser-ai --template advanced --output my_script.py + + \b + openbrowser-ai -p "Search for the latest AI news" + openbrowser-ai -c "await navigate('https://example.com')" + openbrowser-ai --mcp + openbrowser-ai --template default """ # Handle template generation @@ -1906,7 +664,7 @@ def main(ctx: click.Context, debug: bool = False, **kwargs): def run_main_interface(ctx: click.Context, debug: bool = False, **kwargs): - """Run the main openbrowser interface""" + """Run the main openbrowser interface.""" if kwargs['version']: from importlib.metadata import version @@ -1937,89 +695,18 @@ def run_main_interface(ctx: click.Context, debug: bool = False, **kwargs): # Check if prompt mode is activated if kwargs.get('prompt'): + if Agent is None: + print('Error: Agent dependencies not installed.') + print('Install with: pip install "openbrowser-ai[agent]"') + sys.exit(1) # Set environment variable for prompt mode before running os.environ['OPENBROWSER_LOGGING_LEVEL'] = 'result' # Run in non-interactive mode asyncio.run(run_prompt_mode(kwargs['prompt'], ctx, debug)) return - # Configure console logging - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', '%H:%M:%S')) - - # Configure root logger - root_logger = logging.getLogger() - root_logger.setLevel(logging.INFO if not debug else logging.DEBUG) - root_logger.addHandler(console_handler) - - logger = logging.getLogger('openbrowser.startup') - logger.info('Starting OpenBrowser initialization') - if debug: - logger.debug(f'System info: Python {sys.version.split()[0]}, Platform: {sys.platform}') - - logger.debug('Loading environment variables from .env file...') - load_dotenv() - logger.debug('Environment variables loaded') - - # Load user configuration - logger.debug('Loading user configuration...') - try: - config = load_user_config() - logger.debug(f'User configuration loaded from {CONFIG.OPENBROWSER_CONFIG_FILE}') - except Exception as e: - logger.error(f'Error loading user configuration: {str(e)}', exc_info=True) - print(f'Error loading configuration: {str(e)}') - sys.exit(1) - - # Update config with command-line arguments - logger.debug('Updating configuration with command line arguments...') - try: - config = update_config_with_click_args(config, ctx) - logger.debug('Configuration updated') - except Exception as e: - logger.error(f'Error updating config with command line args: {str(e)}', exc_info=True) - print(f'Error updating configuration: {str(e)}') - sys.exit(1) - - # Save updated config - logger.debug('Saving user configuration...') - try: - save_user_config(config) - logger.debug('Configuration saved') - except Exception as e: - logger.error(f'Error saving user configuration: {str(e)}', exc_info=True) - print(f'Error saving configuration: {str(e)}') - sys.exit(1) - - # Setup handlers for console output before entering Textual UI - logger.debug('Setting up handlers for Textual UI...') - - # Log browser and model configuration that will be used - browser_type = 'Chromium' # BrowserSession only supports Chromium - model_name = config.get('model', {}).get('name', 'auto-detected') - headless = config.get('browser', {}).get('headless', False) - headless_str = 'headless' if headless else 'visible' - - logger.info(f'Preparing {browser_type} browser ({headless_str}) with {model_name} LLM') - - try: - # Run the Textual UI interface - now all the initialization happens before we go fullscreen - logger.debug('Starting Textual UI interface...') - asyncio.run(textual_interface(config)) - except Exception as e: - # Restore console logging for error reporting - root_logger.setLevel(logging.INFO) - for handler in root_logger.handlers: - root_logger.removeHandler(handler) - root_logger.addHandler(console_handler) - - logger.error(f'Error initializing OpenBrowser: {str(e)}', exc_info=debug) - print(f'\nError launching OpenBrowser: {str(e)}') - if debug: - import traceback - - traceback.print_exc() - sys.exit(1) + # No specific mode requested -- show help + click.echo(ctx.get_help()) @main.command() diff --git a/src/openbrowser/daemon/server.py b/src/openbrowser/daemon/server.py index 8f3e6054..fc658433 100644 --- a/src/openbrowser/daemon/server.py +++ b/src/openbrowser/daemon/server.py @@ -315,8 +315,8 @@ async def run(self): for sig in (signal.SIGTERM, signal.SIGINT): try: loop.add_signal_handler(sig, self._signal_shutdown) - except NotImplementedError: - pass # Windows + except (NotImplementedError, RuntimeError): + pass # Windows or non-main thread # Start idle timeout checker idle_task = asyncio.create_task(self._idle_check_loop()) diff --git a/src/openbrowser/mcp/server.py b/src/openbrowser/mcp/server.py index 7724da76..3459c84d 100644 --- a/src/openbrowser/mcp/server.py +++ b/src/openbrowser/mcp/server.py @@ -448,7 +448,10 @@ async def _execute_code(self, code: str) -> str: self._last_activity = time.time() # Pre-flight: check if CDP is still alive and recover if needed - if not await self._is_cdp_alive(): + # Only attempt recovery when we already had a browser session (i.e. it died). + # If browser_session is None, _ensure_namespace hasn't launched one yet + # or the code doesn't need a browser -- skip recovery to avoid hanging. + if self.browser_session and not await self._is_cdp_alive(): try: await self._recover_browser_session() except Exception as recovery_err: diff --git a/tests/test_advanced_features.py b/tests/test_advanced_features.py index 46c26e8e..99c1472d 100644 --- a/tests/test_advanced_features.py +++ b/tests/test_advanced_features.py @@ -178,24 +178,28 @@ class TestNewLLMProviders: def test_chat_browser_use_import(self): """Test ChatBrowserUse import.""" + pytest.importorskip("litellm") from openbrowser.llm import ChatBrowserUse assert ChatBrowserUse is not None def test_chat_google_import(self): """Test ChatGoogle import.""" + pytest.importorskip("google.genai") from openbrowser.llm import ChatGoogle assert ChatGoogle is not None def test_chat_openai_import(self): """Test ChatOpenAI import.""" + pytest.importorskip("openai") from openbrowser.llm import ChatOpenAI assert ChatOpenAI is not None def test_chat_anthropic_import(self): """Test ChatAnthropic import.""" + pytest.importorskip("anthropic") from openbrowser.llm import ChatAnthropic assert ChatAnthropic is not None diff --git a/tests/test_cli_c_help.py b/tests/test_cli_c_help.py index 3e0fe549..eba857ce 100644 --- a/tests/test_cli_c_help.py +++ b/tests/test_cli_c_help.py @@ -1,19 +1,27 @@ # tests/test_cli_c_help.py """Tests for `openbrowser-ai -c` (no argument) self-documenting behaviour.""" +import asyncio +import os import subprocess import sys +import threading +import time +import uuid +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock import pytest -def _run_cli(*args: str) -> subprocess.CompletedProcess: +def _run_cli(*args: str, env: dict | None = None) -> subprocess.CompletedProcess: """Run the CLI entry-point as a subprocess.""" return subprocess.run( [sys.executable, '-m', 'openbrowser.cli', *args], capture_output=True, text=True, timeout=30, + env=env, ) @@ -78,7 +86,68 @@ def test_c_no_arg_compact_when_daemon_running(self): ) def test_c_with_code_still_works(self): - """openbrowser-ai -c 'print(1+1)' should still execute code.""" - result = _run_cli('-c', 'print(1+1)') - assert result.returncode == 0 - assert '2' in result.stdout + """openbrowser-ai -c 'print(1+1)' should execute code via a mock daemon.""" + from openbrowser.code_use.executor import CodeExecutor + from openbrowser.daemon.server import DaemonServer + + # Set up a unique temp socket for isolation + test_id = uuid.uuid4().hex[:8] + tmp_dir = Path(f'/tmp/ob_test_{test_id}') + tmp_dir.mkdir(parents=True, exist_ok=True) + sock = tmp_dir / 'd.sock' + sock_str = str(sock) + + # Build a daemon with pre-initialized executor (no browser needed) + server = DaemonServer(idle_timeout=60) + executor = CodeExecutor() + executor.set_namespace({ + 'json': __import__('json'), + 'asyncio': __import__('asyncio'), + }) + server._executor = executor + + loop = asyncio.new_event_loop() + + def run_daemon(): + asyncio.set_event_loop(loop) + os.environ['OPENBROWSER_SOCKET'] = sock_str + try: + loop.run_until_complete(server.run()) + finally: + os.environ.pop('OPENBROWSER_SOCKET', None) + + t = threading.Thread(target=run_daemon, daemon=True) + t.start() + + # Wait for socket to appear + for _ in range(50): + if sock.exists(): + break + time.sleep(0.05) + else: + server._running = False + server._stop_event.set() + t.join(timeout=5) + pytest.fail('Mock daemon socket never appeared') + + try: + env = {**os.environ, 'OPENBROWSER_SOCKET': sock_str} + result = subprocess.run( + [sys.executable, '-m', 'openbrowser.cli', '-c', 'print(1+1)'], + capture_output=True, + text=True, + timeout=30, + env=env, + ) + assert result.returncode == 0, f'stderr: {result.stderr}' + assert '2' in result.stdout + finally: + server._running = False + server._stop_event.set() + t.join(timeout=5) + sock.unlink(missing_ok=True) + (tmp_dir / 'd.pid').unlink(missing_ok=True) + try: + tmp_dir.rmdir() + except OSError: + pass diff --git a/tests/test_daemon_integration.py b/tests/test_daemon_integration.py index a69fef6f..48b4dfdd 100644 --- a/tests/test_daemon_integration.py +++ b/tests/test_daemon_integration.py @@ -25,7 +25,6 @@ def daemon_env(): sock = tmp_dir / 'd.sock' with patch.dict(os.environ, {'OPENBROWSER_SOCKET': str(sock)}), \ - patch('openbrowser.daemon.server.DAEMON_DIR', tmp_dir), \ patch('openbrowser.daemon.client.DAEMON_DIR', tmp_dir): yield sock diff --git a/uv.lock b/uv.lock index 2eeb8ccc..a2338c73 100644 --- a/uv.lock +++ b/uv.lock @@ -705,45 +705,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563, upload-time = "2025-12-17T12:35:03.717Z" }, ] -[[package]] -name = "greenlet" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, - { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, - { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, - { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, - { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, - { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, - { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, - { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, - { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, - { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, -] - [[package]] name = "groq" version = "1.0.0" @@ -1165,18 +1126,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/8a/d9bc95607846bc82fbe0b98d2592ffb5e036c97a362735ae926e3d519df7/langsmith-0.5.0-py3-none-any.whl", hash = "sha256:a83750cb3dccb33148d4ffe005e3e03080fad13e01671efbb74c9a68813bfef8", size = 273711, upload-time = "2025-12-16T17:35:37.165Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, -] - [[package]] name = "litellm" version = "1.80.0" @@ -1212,14 +1161,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] -plugins = [ - { name = "mdit-py-plugins" }, -] - [[package]] name = "markdownify" version = "1.2.2" @@ -1321,18 +1262,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1545,7 +1474,6 @@ dependencies = [ { name = "httpx" }, { name = "mcp" }, { name = "pillow" }, - { name = "playwright" }, { name = "posthog" }, { name = "psutil" }, { name = "pydantic" }, @@ -1589,7 +1517,6 @@ all = [ { name = "pypdf" }, { name = "reportlab" }, { name = "rich" }, - { name = "textual" }, ] anthropic = [ { name = "anthropic" }, @@ -1600,9 +1527,6 @@ aws = [ azure = [ { name = "openai" }, ] -cli = [ - { name = "textual" }, -] dev = [ { name = "mcp" }, { name = "pytest" }, @@ -1676,7 +1600,6 @@ requires-dist = [ { name = "pandas", marker = "extra == 'agent'", specifier = ">=2.2.0" }, { name = "pandas", marker = "extra == 'all'", specifier = ">=2.2.0" }, { name = "pillow", specifier = ">=11.0.0" }, - { name = "playwright" }, { name = "posthog", specifier = ">=3.7.0" }, { name = "posthog", marker = "extra == 'mcp'", specifier = ">=3.7.0" }, { name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.7.0" }, @@ -1696,11 +1619,9 @@ requires-dist = [ { name = "reportlab", marker = "extra == 'pdf'" }, { name = "rich", marker = "extra == 'agent'", specifier = ">=13.0.0" }, { name = "rich", marker = "extra == 'all'", specifier = ">=13.0.0" }, - { name = "textual", marker = "extra == 'all'", specifier = ">=3.2.0" }, - { name = "textual", marker = "extra == 'cli'", specifier = ">=3.2.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["agent", "all", "anthropic", "aws", "azure", "cli", "dev", "groq", "mcp", "ollama", "pdf", "telemetry", "video"] +provides-extras = ["agent", "all", "anthropic", "aws", "azure", "dev", "groq", "mcp", "ollama", "pdf", "telemetry", "video"] [[package]] name = "orjson" @@ -1918,34 +1839,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, ] -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - -[[package]] -name = "playwright" -version = "1.57.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" }, - { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" }, - { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -2226,18 +2119,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] -[[package]] -name = "pyee" -version = "13.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -2744,22 +2625,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] -[[package]] -name = "textual" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" }, -] - [[package]] name = "tiktoken" version = "0.12.0" @@ -2887,15 +2752,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] -[[package]] -name = "uc-micro-py" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, -] - [[package]] name = "urllib3" version = "2.6.2"