From 5de683f7a7058b020d6a0f87fd4ae94371ab40a9 Mon Sep 17 00:00:00 2001 From: malice Date: Sun, 5 Jul 2026 16:21:32 -0600 Subject: [PATCH] chore: remove legacy TUI configurator; surface last config gap in GUI (#45) Audit confirmed the Rich TUI (`meshai --config`) is a strict subset of the dashboard GUI (covers FEWER sections) and nothing depends on it for bootstrap. Strip it so the dashboard is the single config surface. - delete meshai/cli/configurator.py (~1435 lines); cli/__init__ minimal - main.py: drop --config + run_configurator dispatch (keep --config-file); bootstrap log now points to config.example.yaml / the dashboard - docker-entrypoint.sh: remove the ttyd `meshai --config` block on :7682 (default-config heredoc + bot exec loop untouched) - docker-compose.yml + Dockerfile: drop the 7682 port + ttyd install (EXPOSE 8080) - README / config.py header / config.example.yaml: de-reference the TUI - drop `rich` from pyproject + requirements (grep-verified unused) Close the only GUI config gap the audit found: - Config.tsx weather tab: add openmeteo.url + wttr.url inputs (backend already round-trips the nested WeatherConfig dataclasses) Bootstrap intact: config.example.yaml copy, entrypoint default-config write, and defaulted Config() when no file exists all remain. `python3 -m meshai` and `--config-file` unchanged. TS/import validated at Docker build. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/Dockerfile | 12 +- work/README.md | 2 +- work/config.example.yaml | 4 +- work/dashboard-frontend/src/pages/Config.tsx | 14 + work/docker-compose.yml | 10 +- work/docker-entrypoint.sh | 18 +- work/meshai/cli/__init__.py | 4 - work/meshai/cli/configurator.py | 1434 ------------------ work/meshai/config.py | 2 +- work/meshai/main.py | 11 +- work/pyproject.toml | 1 - work/requirements.txt | 1 - 12 files changed, 30 insertions(+), 1483 deletions(-) delete mode 100644 work/meshai/cli/configurator.py diff --git a/work/Dockerfile b/work/Dockerfile index 0dec6df..a2f5e48 100644 --- a/work/Dockerfile +++ b/work/Dockerfile @@ -46,11 +46,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ # For process management procps \ - && rm -rf /var/lib/apt/lists/* \ - # Install ttyd for web-based config interface (arch-aware) - && TTYD_ARCH=$(dpkg --print-architecture | sed 's/amd64/x86_64/' | sed 's/arm64/aarch64/') \ - && curl -sL "https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.${TTYD_ARCH}" -o /usr/local/bin/ttyd \ - && chmod +x /usr/local/bin/ttyd + && rm -rf /var/lib/apt/lists/* # Create non-root user RUN groupadd -g ${GID} meshai && \ @@ -88,12 +84,12 @@ USER meshai # Data volume mount point VOLUME ["/data"] -# Expose ttyd web config port -EXPOSE 7682 8080 +# Expose dashboard port +EXPOSE 8080 # Health check - verify bot process is alive via PID file HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null && [ "$(cat /tmp/meshai.link 2>/dev/null)" = up ] || exit 1 -# Entrypoint handles config and ttyd +# Entrypoint writes default config on first run, then starts the bot ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/work/README.md b/work/README.md index 9320842..d6ede51 100644 --- a/work/README.md +++ b/work/README.md @@ -56,7 +56,7 @@ Everything is driven from the web UI, organized into **General**, **Meshtastic** git clone https://github.com/zvx-echo6/meshai.git cd meshai pip install -e . -meshai --config # interactive setup TUI +cp config.example.yaml config.yaml # then edit config.yaml (or use the dashboard) meshai ``` diff --git a/work/config.example.yaml b/work/config.example.yaml index 744fb45..0bdb8b8 100644 --- a/work/config.example.yaml +++ b/work/config.example.yaml @@ -83,8 +83,8 @@ knowledge: # === MESH DATA SOURCES === # Connect to Meshview and/or MeshMonitor instances for live mesh -# network analysis. Supports multiple sources. Configure via TUI -# with meshai --config (Mesh Sources menu). +# network analysis. Supports multiple sources. Configure via the +# dashboard (Mesh Sources) or by editing this file directly. # # mesh_sources: # - name: "my-meshview" diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index 336bc9c..f1bbda9 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -1137,6 +1137,20 @@ function WeatherSection({ data, onChange }: { data: WeatherConfig; onChange: (d: placeholder="Your city, state" helper="Location when none specified" /> + onChange({ ...data, openmeteo: { ...data.openmeteo, url: v } })} + placeholder="https://api.open-meteo.com/v1" + helper="Override the Open-Meteo API endpoint (leave default unless self-hosting)" + /> + onChange({ ...data, wttr: { ...data.wttr, url: v } })} + placeholder="https://wttr.in" + helper="Override the wttr.in endpoint (leave default unless self-hosting)" + /> ) } diff --git a/work/docker-compose.yml b/work/docker-compose.yml index cc7ca64..6d06d77 100644 --- a/work/docker-compose.yml +++ b/work/docker-compose.yml @@ -1,15 +1,15 @@ # MeshAI Docker Compose Configuration # # Usage: -# docker compose up -d # Start bot + web config +# docker compose up -d # Start the bot + dashboard # docker compose logs -f # View logs # -# Web config: http://localhost:7682 (TUI in browser) +# Dashboard: http://localhost:8080 # # Config is stored in the meshai_data volume at /data/config.yaml # # For serial connection (USB), uncomment the devices section below -# For TCP connection, configure via web interface +# For TCP connection, edit config.yaml or use the dashboard services: meshai: @@ -41,8 +41,6 @@ services: # - /dev/ttyACM0:/dev/ttyACM0 ports: - # Web-based config interface (ttyd) - - "7682:7682" # Dashboard API - "8080:8080" @@ -50,7 +48,7 @@ services: # Persistent data (database, config) - meshai_data:/data - # Run interactively for first-time setup wizard + # Allocate a TTY for cleaner log output stdin_open: true tty: true diff --git a/work/docker-entrypoint.sh b/work/docker-entrypoint.sh index 6cc0851..69cd103 100755 --- a/work/docker-entrypoint.sh +++ b/work/docker-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash # MeshAI Docker Entrypoint -# Runs ttyd for web config access and the bot +# Writes a default config on first run, then runs the bot export MESHAI_CONFIG="/data/config.yaml" export TERM="${TERM:-xterm-256color}" @@ -10,7 +10,7 @@ if [ ! -f "$MESHAI_CONFIG" ]; then mkdir -p /data cat > "$MESHAI_CONFIG" << 'EOF' # MeshAI Configuration -# Configure via http://localhost:7682 +# Edit this file directly, or configure via the dashboard bot: name: ai @@ -66,22 +66,10 @@ meshmonitor: enabled: false inject_into_prompt: true EOF - echo "Default config created. Configure via http://localhost:7682" + echo "Default config created at $MESHAI_CONFIG. Edit it or configure via the dashboard." fi -# Start ttyd for web-based config access -echo "Starting web config interface on port 7682..." -ttyd -W -p 7682 \ - -t enableClipboard=true \ - -t titleFixed="MeshAI Config" \ - -t 'theme={"background":"#0d1117","foreground":"#c9d1d9","cursor":"#58a6ff","selectionBackground":"#388bfd"}' \ - -t fontSize=14 \ - /bin/bash -c 'while true; do python3 -m meshai --config-file "$MESHAI_CONFIG" --config; sleep 1; done' & - -# Keep ttyd running even if bot fails -trap "kill %1 2>/dev/null" EXIT - # Kill bot gracefully with SIGKILL fallback kill_bot() { local pid=$1 diff --git a/work/meshai/cli/__init__.py b/work/meshai/cli/__init__.py index 3577b89..0b88ffa 100644 --- a/work/meshai/cli/__init__.py +++ b/work/meshai/cli/__init__.py @@ -1,5 +1 @@ """CLI tools for MeshAI.""" - -from .configurator import run_configurator - -__all__ = ["run_configurator"] diff --git a/work/meshai/cli/configurator.py b/work/meshai/cli/configurator.py deleted file mode 100644 index 66a8d1d..0000000 --- a/work/meshai/cli/configurator.py +++ /dev/null @@ -1,1434 +0,0 @@ -"""Rich-based TUI configurator for MeshAI.""" - -import time -from pathlib import Path -from typing import Optional - -from rich import box -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table -from rich.text import Text - -from ..config import Config, MeshSourceConfig, load_config, save_config - -console = Console() - - -class Configurator: - """Interactive configuration tool for MeshAI.""" - - def __init__(self, config_path: Optional[Path] = None): - self.config_path = config_path or Path("config.yaml") - self.config: Config = load_config(self.config_path) - self.modified = False - - def run(self) -> None: - """Run the configurator.""" - try: - self._show_welcome() - self._main_menu() - except KeyboardInterrupt: - self._handle_exit() - - def _clear(self) -> None: - """Clear the screen.""" - console.clear() - - def _show_welcome(self) -> None: - """Display welcome header.""" - self._clear() - header = Panel( - Text( - "MeshAI Configuration Tool\n" - "Configure your Meshtastic LLM assistant", - justify="center", - style="cyan", - ), - title="[yellow]Welcome[/yellow]", - border_style="blue", - ) - console.print(header) - console.print() - - def _status_icon(self, value: bool) -> str: - """Return colored status icon.""" - return "[green]✓[/green]" if value else "[red]✗[/red]" - - def _main_menu(self) -> None: - """Display and handle main menu.""" - while True: - self._clear() - self._show_header() - - table = Table(box=box.ROUNDED, show_header=False) - table.add_column("Option", style="cyan", width=4) - table.add_column("Description", style="white") - table.add_column("Status", style="dim") - - disabled_count = len(self.config.commands.disabled_commands) - cmd_status = f"{disabled_count} disabled" if disabled_count else "all enabled" - - table.add_row("1", "Bot Settings", self.config.bot.name) - table.add_row("2", "Connection", f"{self.config.connection.type}") - table.add_row("3", "LLM Backend", f"{self.config.llm.backend}/{self.config.llm.model}") - table.add_row("4", "Response Settings", f"{self.config.response.max_length}ch max") - table.add_row("5", "History & Memory", f"{self.config.history.max_messages_per_user} msgs") - table.add_row("6", "Commands", cmd_status) - ctx_status = self._status_icon(self.config.context.enabled) - table.add_row("7", "Context", f"{ctx_status} {self.config.context.max_context_items} items") - table.add_row("8", "Weather", f"{self.config.weather.primary}") - mm_status = self._status_icon(self.config.meshmonitor.enabled) - mm_url = self.config.meshmonitor.url or "[dim]not set[/dim]" - table.add_row("9", "MeshMonitor Sync", f"{mm_status} {mm_url}") - kb_status = self._status_icon(self.config.knowledge.enabled) - kb_path = self.config.knowledge.db_path or "[dim]not set[/dim]" - table.add_row("10", "Knowledge Base", f"{kb_status} {kb_path}") - - # Mesh Sources - total_sources = len(self.config.mesh_sources) - enabled_sources = sum(1 for s in self.config.mesh_sources if s.enabled) - src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]" - table.add_row("11", "Mesh Sources", src_status) - - # Mesh Intelligence - mi_status = self._status_icon(self.config.mesh_intelligence.enabled) - mi_regions = len(self.config.mesh_intelligence.regions) - mi_info = f"{mi_regions} regions" if mi_regions else "[dim]auto[/dim]" - table.add_row("12", "Mesh Intelligence", f"{mi_status} {mi_info}") - - table.add_row("13", "Setup Wizard", "[dim]First-time setup[/dim]") - - console.print(table) - console.print() - - # Exit options - if self.modified: - console.print("[yellow]* Unsaved changes[/yellow]") - console.print() - console.print("[white]14. Save[/white] [dim]Save config, stay in menu[/dim]") - console.print("[green]15. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") - console.print("[white]16. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") - console.print("[white]17. Exit without Saving[/white]") - console.print() - - choice = IntPrompt.ask("Select option", default=15) - - if choice == 1: - self._bot_settings() - elif choice == 2: - self._connection_settings() - elif choice == 3: - self._llm_settings() - elif choice == 4: - self._response_settings() - elif choice == 5: - self._history_settings() - elif choice == 6: - self._command_settings() - elif choice == 7: - self._context_settings() - elif choice == 8: - self._weather_settings() - elif choice == 9: - self._meshmonitor_settings() - elif choice == 10: - self._knowledge_settings() - elif choice == 11: - self._mesh_sources_settings() - elif choice == 12: - self._mesh_intelligence_settings() - elif choice == 13: - self._setup_wizard() - elif choice == 14: - self._save_only() - elif choice == 15: - self._save_and_restart() - elif choice == 16: - self._save_restart_exit() - break - elif choice == 17: - break - - def _show_header(self) -> None: - """Show compact header with modified indicator.""" - title = "[bold cyan]MeshAI Configuration[/bold cyan]" - if self.modified: - title += " [yellow]*[/yellow]" - console.print(Panel(title, box=box.MINIMAL)) - - def _handle_exit(self) -> None: - """Handle exit (keyboard interrupt).""" - if self.modified: - if Confirm.ask("\nSave changes before exiting?", default=True): - save_config(self.config, self.config_path) - console.print("[green]Saved.[/green]") - console.print("\nGoodbye!") - - def _bot_settings(self) -> None: - """Bot settings submenu.""" - while True: - self._clear() - console.print("[bold]Bot Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Bot Name", self.config.bot.name) - table.add_row("2", "Owner", self.config.bot.owner or "[dim]not set[/dim]") - table.add_row( - "3", "Respond to DMs", self._status_icon(self.config.bot.respond_to_dms) - ) - table.add_row( - "4", "Filter BBS Protocols", self._status_icon(self.config.bot.filter_bbs_protocols) - ) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Prompt.ask("Bot name", default=self.config.bot.name) - if value != self.config.bot.name: - self.config.bot.name = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Owner", default=self.config.bot.owner) - if value != self.config.bot.owner: - self.config.bot.owner = value - self.modified = True - elif choice == 3: - value = Confirm.ask("Respond to DMs?", default=self.config.bot.respond_to_dms) - if value != self.config.bot.respond_to_dms: - self.config.bot.respond_to_dms = value - self.modified = True - elif choice == 4: - value = Confirm.ask("Filter BBS protocols?", default=self.config.bot.filter_bbs_protocols) - if value != self.config.bot.filter_bbs_protocols: - self.config.bot.filter_bbs_protocols = value - self.modified = True - - def _connection_settings(self) -> None: - """Connection settings submenu.""" - while True: - self._clear() - console.print("[bold]Connection Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Connection Type", self.config.connection.type) - table.add_row("2", "Serial Port", self.config.connection.serial_port) - table.add_row("3", "TCP Host", self.config.connection.tcp_host) - table.add_row("4", "TCP Port", str(self.config.connection.tcp_port)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] serial - USB Serial connection") - console.print("[cyan]2.[/cyan] tcp - TCP Network connection") - sel = IntPrompt.ask("Select", default=1 if self.config.connection.type == "serial" else 2) - value = "serial" if sel == 1 else "tcp" - if value != self.config.connection.type: - self.config.connection.type = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Serial port", default=self.config.connection.serial_port) - if value != self.config.connection.serial_port: - self.config.connection.serial_port = value - self.modified = True - elif choice == 3: - value = Prompt.ask("TCP host", default=self.config.connection.tcp_host) - if value != self.config.connection.tcp_host: - self.config.connection.tcp_host = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("TCP port", default=self.config.connection.tcp_port) - if value != self.config.connection.tcp_port: - self.config.connection.tcp_port = value - self.modified = True - - def _llm_settings(self) -> None: - """LLM backend settings submenu.""" - while True: - self._clear() - console.print("[bold]LLM Backend Settings[/bold]\n") - - # Mask API key for display - api_key_display = "****" + self.config.llm.api_key[-4:] if len(self.config.llm.api_key) > 4 else "[dim]not set[/dim]" - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Backend", self.config.llm.backend) - table.add_row("2", "API Key", api_key_display) - table.add_row("3", "Base URL", self.config.llm.base_url) - table.add_row("4", "Model", self.config.llm.model) - table.add_row("5", "System Prompt", f"[dim]{len(self.config.llm.system_prompt)} chars[/dim]") - table.add_row("6", "Use System Prompt", self._status_icon(self.config.llm.use_system_prompt)) - table.add_row("7", "Web Search", self._status_icon(self.config.llm.web_search)) - table.add_row("8", "Google Grounding", self._status_icon(self.config.llm.google_grounding)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible (LiteLLM, etc)") - console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") - console.print("[cyan]3.[/cyan] google - Google Gemini") - sel = IntPrompt.ask("Select", default=1) - backends = {1: "openai", 2: "anthropic", 3: "google"} - value = backends.get(sel, "openai") - if value != self.config.llm.backend: - self.config.llm.backend = value - self.modified = True - elif choice == 2: - value = Prompt.ask("API Key", password=True) - if value: - self.config.llm.api_key = value - self.modified = True - elif choice == 3: - value = Prompt.ask("Base URL", default=self.config.llm.base_url) - if value != self.config.llm.base_url: - self.config.llm.base_url = value - self.modified = True - elif choice == 4: - value = Prompt.ask("Model", default=self.config.llm.model) - if value != self.config.llm.model: - self.config.llm.model = value - self.modified = True - elif choice == 5: - console.print("\n[dim]Current prompt:[/dim]") - console.print(self.config.llm.system_prompt or "(empty)") - console.print() - if Confirm.ask("Edit system prompt?", default=False): - console.print("[dim]Enter new prompt, or leave empty to clear[/dim]") - value = Prompt.ask("New system prompt", default="") - if value != self.config.llm.system_prompt: - self.config.llm.system_prompt = value - self.modified = True - elif choice == 6: - self.config.llm.use_system_prompt = not self.config.llm.use_system_prompt - self.modified = True - elif choice == 7: - self.config.llm.web_search = not self.config.llm.web_search - self.modified = True - elif choice == 8: - if self.config.llm.backend == "google": - self.config.llm.google_grounding = not self.config.llm.google_grounding - self.modified = True - else: - console.print("[yellow]Google grounding is only available with the google backend.[/yellow]") - input("Press Enter to continue...") - - def _command_settings(self) -> None: - """Command settings submenu.""" - # All built-in commands - builtin = ["help", "ping", "status", "weather", "reset", "clear"] - - while True: - self._clear() - console.print("[bold]Command Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Command", style="white") - table.add_column("Status", style="green") - - disabled = set(c.lower() for c in self.config.commands.disabled_commands) - for i, cmd in enumerate(builtin, 1): - status = "[red]disabled[/red]" if cmd in disabled else "[green]enabled[/green]" - table.add_row(str(i), f"!{cmd}", status) - - table.add_row("", "", "") - table.add_row("7", "Command Prefix", self.config.commands.prefix) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif 1 <= choice <= len(builtin): - cmd = builtin[choice - 1] - if cmd in disabled: - self.config.commands.disabled_commands.remove(cmd) - console.print(f"[green]!{cmd} enabled[/green]") - else: - self.config.commands.disabled_commands.append(cmd) - console.print(f"[red]!{cmd} disabled[/red]") - self.modified = True - elif choice == 7: - value = Prompt.ask("Command prefix", default=self.config.commands.prefix) - if value != self.config.commands.prefix: - self.config.commands.prefix = value - self.modified = True - - def _context_settings(self) -> None: - """Mesh context settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Context Settings[/bold]\n") - console.print("[dim]Passively observes channel traffic to give the LLM situational awareness.[/dim]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - channels = self.config.context.observe_channels - ch_display = ", ".join(str(c) for c in channels) if channels else "[dim]all[/dim]" - nodes = self.config.context.ignore_nodes - node_display = ", ".join(nodes) if nodes else "[dim]none[/dim]" - age_days = self.config.context.max_age // 86400 - - table.add_row("1", "Enabled", self._status_icon(self.config.context.enabled)) - table.add_row("2", "Observe Channels", ch_display) - table.add_row("3", "Ignore Nodes", node_display) - table.add_row("4", "Max Age", f"{age_days}d") - table.add_row("5", "Max Context Items", str(self.config.context.max_context_items)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self.config.context.enabled = not self.config.context.enabled - self.modified = True - elif choice == 2: - console.print("\n[dim]Enter channel indices separated by commas, or leave empty for all.[/dim]") - value = Prompt.ask("Channels", default=", ".join(str(c) for c in channels)) - parsed = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()] if value.strip() else [] - if parsed != self.config.context.observe_channels: - self.config.context.observe_channels = parsed - self.modified = True - elif choice == 3: - console.print("\n[dim]Enter node IDs separated by commas, or leave empty for none.[/dim]") - value = Prompt.ask("Node IDs", default=", ".join(nodes)) - parsed = [x.strip() for x in value.split(",") if x.strip()] if value.strip() else [] - if parsed != self.config.context.ignore_nodes: - self.config.context.ignore_nodes = parsed - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Max age (days)", default=age_days) - seconds = value * 86400 - if seconds != self.config.context.max_age: - self.config.context.max_age = seconds - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Max context items", default=self.config.context.max_context_items) - if value != self.config.context.max_context_items: - self.config.context.max_context_items = value - self.modified = True - - def _weather_settings(self) -> None: - """Weather settings submenu.""" - while True: - self._clear() - console.print("[bold]Weather Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Primary Provider", self.config.weather.primary) - table.add_row("2", "Fallback Provider", self.config.weather.fallback) - table.add_row("3", "Default Location", self.config.weather.default_location or "[dim]not set[/dim]") - table.add_row("4", "Open-Meteo URL", self.config.weather.openmeteo.url) - table.add_row("5", "wttr.in URL", self.config.weather.wttr.url) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] openmeteo - Open-Meteo API (free, no key)") - console.print("[cyan]2.[/cyan] wttr - wttr.in (free, simple)") - console.print("[cyan]3.[/cyan] llm - Use LLM with web search") - sel = IntPrompt.ask("Select", default=1) - providers = {1: "openmeteo", 2: "wttr", 3: "llm"} - value = providers.get(sel, "openmeteo") - if value != self.config.weather.primary: - self.config.weather.primary = value - self.modified = True - elif choice == 2: - console.print("\n[cyan]1.[/cyan] openmeteo") - console.print("[cyan]2.[/cyan] wttr") - console.print("[cyan]3.[/cyan] llm") - console.print("[cyan]4.[/cyan] none - No fallback") - sel = IntPrompt.ask("Select", default=3) - providers = {1: "openmeteo", 2: "wttr", 3: "llm", 4: "none"} - value = providers.get(sel, "llm") - if value != self.config.weather.fallback: - self.config.weather.fallback = value - self.modified = True - elif choice == 3: - value = Prompt.ask("Default location", default=self.config.weather.default_location) - if value != self.config.weather.default_location: - self.config.weather.default_location = value - self.modified = True - elif choice == 4: - value = Prompt.ask("Open-Meteo URL", default=self.config.weather.openmeteo.url) - if value != self.config.weather.openmeteo.url: - self.config.weather.openmeteo.url = value - self.modified = True - elif choice == 5: - value = Prompt.ask("wttr.in URL", default=self.config.weather.wttr.url) - if value != self.config.weather.wttr.url: - self.config.weather.wttr.url = value - self.modified = True - - def _response_settings(self) -> None: - """Response settings submenu.""" - while True: - self._clear() - console.print("[bold]Response Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Min Delay (seconds)", str(self.config.response.delay_min)) - table.add_row("2", "Max Delay (seconds)", str(self.config.response.delay_max)) - table.add_row("3", "Max Length (chars)", str(self.config.response.max_length)) - table.add_row("4", "Max Messages", str(self.config.response.max_messages)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = float(Prompt.ask("Min delay", default=str(self.config.response.delay_min))) - if value != self.config.response.delay_min: - self.config.response.delay_min = value - self.modified = True - elif choice == 2: - value = float(Prompt.ask("Max delay", default=str(self.config.response.delay_max))) - if value != self.config.response.delay_max: - self.config.response.delay_max = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Max length", default=self.config.response.max_length) - if value != self.config.response.max_length: - self.config.response.max_length = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Max messages", default=self.config.response.max_messages) - if value != self.config.response.max_messages: - self.config.response.max_messages = value - self.modified = True - - def _history_settings(self) -> None: - """History settings submenu.""" - while True: - self._clear() - console.print("[bold]History & Memory Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - timeout_hours = self.config.history.conversation_timeout // 3600 - table.add_row("1", "Database File", self.config.history.database) - table.add_row("2", "Max Messages Per User", str(self.config.history.max_messages_per_user)) - table.add_row("3", "Conversation Timeout", f"{timeout_hours}h") - table.add_row("4", "Auto Cleanup", self._status_icon(self.config.history.auto_cleanup)) - table.add_row("5", "Max Age (days)", str(self.config.history.max_age_days)) - table.add_row("", "[bold]Memory[/bold]", "") - table.add_row("6", "Memory Enabled", self._status_icon(self.config.memory.enabled)) - table.add_row("7", "Window Size", str(self.config.memory.window_size)) - table.add_row("8", "Summarize Threshold", str(self.config.memory.summarize_threshold)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Prompt.ask("Database file", default=self.config.history.database) - if value != self.config.history.database: - self.config.history.database = value - self.modified = True - elif choice == 2: - value = IntPrompt.ask( - "Max messages per user", default=self.config.history.max_messages_per_user - ) - if value != self.config.history.max_messages_per_user: - self.config.history.max_messages_per_user = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Timeout (hours)", default=timeout_hours) - seconds = value * 3600 - if seconds != self.config.history.conversation_timeout: - self.config.history.conversation_timeout = seconds - self.modified = True - elif choice == 4: - value = Confirm.ask("Enable auto cleanup?", default=self.config.history.auto_cleanup) - if value != self.config.history.auto_cleanup: - self.config.history.auto_cleanup = value - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Max age (days)", default=self.config.history.max_age_days) - if value != self.config.history.max_age_days: - self.config.history.max_age_days = value - self.modified = True - elif choice == 6: - value = Confirm.ask("Enable memory?", default=self.config.memory.enabled) - if value != self.config.memory.enabled: - self.config.memory.enabled = value - self.modified = True - elif choice == 7: - value = IntPrompt.ask("Window size", default=self.config.memory.window_size) - if value != self.config.memory.window_size: - self.config.memory.window_size = value - self.modified = True - elif choice == 8: - value = IntPrompt.ask("Summarize threshold", default=self.config.memory.summarize_threshold) - if value != self.config.memory.summarize_threshold: - self.config.memory.summarize_threshold = value - self.modified = True - - def _meshmonitor_settings(self) -> None: - """MeshMonitor sync settings submenu.""" - while True: - self._clear() - console.print("[bold]MeshMonitor Sync Settings[/bold]\n") - console.print("[dim]Sync auto-responder triggers from MeshMonitor to avoid duplicate responses.[/dim]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(self.config.meshmonitor.enabled)) - table.add_row("2", "MeshMonitor URL", self.config.meshmonitor.url or "[dim]not set[/dim]") - table.add_row("3", "Inject into Prompt", self._status_icon(self.config.meshmonitor.inject_into_prompt)) - table.add_row("4", "Refresh Interval", f"{self.config.meshmonitor.refresh_interval}s") - table.add_row("5", "View Triggers", "[dim]Fetch and display[/dim]") - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled - self.modified = True - elif choice == 2: - value = Prompt.ask("MeshMonitor URL (e.g., http://100.64.0.11:3333)", - default=self.config.meshmonitor.url) - if value != self.config.meshmonitor.url: - self.config.meshmonitor.url = value - self.modified = True - elif choice == 3: - self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Refresh interval (seconds)", default=self.config.meshmonitor.refresh_interval) - if value != self.config.meshmonitor.refresh_interval: - self.config.meshmonitor.refresh_interval = value - self.modified = True - elif choice == 5: - self._view_meshmonitor_triggers() - - def _view_meshmonitor_triggers(self) -> None: - """Fetch and display MeshMonitor triggers.""" - self._clear() - console.print("[bold]MeshMonitor Triggers[/bold]\n") - - if not self.config.meshmonitor.url: - console.print("[yellow]MeshMonitor URL not configured.[/yellow]") - input("\nPress Enter to continue...") - return - - console.print(f"[dim]Fetching from {self.config.meshmonitor.url}...[/dim]\n") - - try: - from ..meshmonitor import MeshMonitorSync - sync = MeshMonitorSync(self.config.meshmonitor.url) - count = sync.load() - - if count == 0: - if sync.last_error: - console.print(f"[red]Error: {sync.last_error}[/red]") - else: - console.print("[yellow]No triggers configured in MeshMonitor.[/yellow]") - else: - console.print(f"[green]Loaded {count} triggers:[/green]\n") - for trigger in sync.raw_triggers: - console.print(f" [cyan]{trigger}[/cyan]") - except Exception as e: - console.print(f"[red]Failed to fetch triggers: {e}[/red]") - - input("\nPress Enter to continue...") - - - def _knowledge_settings(self) -> None: - """Knowledge base settings submenu.""" - while True: - self._clear() - console.print("[bold]Knowledge Base Settings[/bold]\n") - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(self.config.knowledge.enabled)) - table.add_row("2", "Database Path", self.config.knowledge.db_path or "[dim]not set[/dim]") - table.add_row("3", "Results Count", str(self.config.knowledge.top_k)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Confirm.ask("Enable knowledge base?", default=self.config.knowledge.enabled) - if value != self.config.knowledge.enabled: - self.config.knowledge.enabled = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Database path", default=self.config.knowledge.db_path) - if value != self.config.knowledge.db_path: - self.config.knowledge.db_path = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Results count (top_k)", default=self.config.knowledge.top_k) - if value != self.config.knowledge.top_k: - self.config.knowledge.top_k = value - self.modified = True - - def _mesh_sources_settings(self) -> None: - """Mesh data sources settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Data Sources[/bold]\n") - console.print("[dim]Connect to Meshview and/or MeshMonitor instances for live mesh data.[/dim]\n") - - # Display configured sources - if self.config.mesh_sources: - table = Table(box=box.ROUNDED) - table.add_column("#", style="cyan", width=3) - table.add_column("Name", style="white") - table.add_column("Type", style="blue") - table.add_column("URL", style="dim") - table.add_column("Enabled", style="green") - - for i, src in enumerate(self.config.mesh_sources, 1): - table.add_row( - str(i), - src.name, - src.type, - src.url[:40] + "..." if len(src.url) > 40 else src.url, - self._status_icon(src.enabled), - ) - console.print(table) - else: - console.print("[dim]No sources configured.[/dim]") - - console.print() - console.print("[cyan]1.[/cyan] Add source") - console.print("[cyan]2.[/cyan] Edit source") - console.print("[cyan]3.[/cyan] Remove source") - console.print("[cyan]4.[/cyan] Test source") - console.print("[cyan]0.[/cyan] Back") - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self._add_mesh_source() - elif choice == 2: - self._edit_mesh_source() - elif choice == 3: - self._remove_mesh_source() - elif choice == 4: - self._test_mesh_source() - - def _add_mesh_source(self) -> None: - """Add a new mesh data source.""" - self._clear() - console.print("[bold]Add Mesh Source[/bold]\n") - - # Get name - existing_names = {s.name for s in self.config.mesh_sources} - while True: - name = Prompt.ask("Source name (unique identifier)") - if not name: - console.print("[yellow]Name is required.[/yellow]") - continue - if name in existing_names: - console.print(f"[yellow]Name '{name}' already exists. Choose another.[/yellow]") - continue - break - - # Get type - console.print("\n[cyan]1.[/cyan] meshview - Meshview instance") - console.print("[cyan]2.[/cyan] meshmonitor - MeshMonitor instance") - type_choice = IntPrompt.ask("Source type", default=1) - source_type = "meshview" if type_choice == 1 else "meshmonitor" - - # Get URL - url = Prompt.ask("URL (e.g., https://meshview.example.com or http://192.168.1.100:3333)") - - # Get API token (MeshMonitor only) - api_token = "" - if source_type == "meshmonitor": - console.print("\n[dim]API token is required for MeshMonitor. Use ${ENV_VAR} for env vars.[/dim]") - api_token = Prompt.ask("API token", default="") - - # Get refresh interval - refresh_interval = IntPrompt.ask("Refresh interval (seconds)", default=300) - - # Create and add source - source = MeshSourceConfig( - name=name, - type=source_type, - url=url, - api_token=api_token, - refresh_interval=refresh_interval, - enabled=True, - ) - self.config.mesh_sources.append(source) - self.modified = True - - console.print(f"\n[green]Source '{name}' added.[/green]") - input("Press Enter to continue...") - - def _edit_mesh_source(self) -> None: - """Edit an existing mesh data source.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to edit.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Edit Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - status = "[green]enabled[/green]" if src.enabled else "[red]disabled[/red]" - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type}) - {status}") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to edit", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - - while True: - self._clear() - console.print(f"[bold]Edit Source: {src.name}[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Name", src.name) - table.add_row("2", "Type", src.type) - table.add_row("3", "URL", src.url) - if src.type == "meshmonitor": - token_display = "****" + src.api_token[-4:] if len(src.api_token) > 4 else src.api_token or "[dim]not set[/dim]" - table.add_row("4", "API Token", token_display) - table.add_row("5", "Refresh Interval", f"{src.refresh_interval}s") - table.add_row("6", "Enabled", self._status_icon(src.enabled)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - opt = IntPrompt.ask("Select option", default=0) - - if opt == 0: - return - elif opt == 1: - existing_names = {s.name for s in self.config.mesh_sources if s != src} - value = Prompt.ask("Name", default=src.name) - if value and value not in existing_names: - src.name = value - self.modified = True - elif value in existing_names: - console.print("[yellow]Name already exists.[/yellow]") - elif opt == 2: - console.print("\n[cyan]1.[/cyan] meshview") - console.print("[cyan]2.[/cyan] meshmonitor") - t = IntPrompt.ask("Type", default=1 if src.type == "meshview" else 2) - new_type = "meshview" if t == 1 else "meshmonitor" - if new_type != src.type: - src.type = new_type - self.modified = True - elif opt == 3: - value = Prompt.ask("URL", default=src.url) - if value != src.url: - src.url = value - self.modified = True - elif opt == 4 and src.type == "meshmonitor": - value = Prompt.ask("API Token", default=src.api_token) - if value != src.api_token: - src.api_token = value - self.modified = True - elif opt == 5: - value = IntPrompt.ask("Refresh interval (seconds)", default=src.refresh_interval) - if value != src.refresh_interval: - src.refresh_interval = value - self.modified = True - elif opt == 6: - src.enabled = not src.enabled - self.modified = True - - def _remove_mesh_source(self) -> None: - """Remove a mesh data source.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to remove.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Remove Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to remove", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - if Confirm.ask(f"Remove source '{src.name}'?", default=False): - self.config.mesh_sources.pop(choice - 1) - self.modified = True - console.print(f"[green]Source '{src.name}' removed.[/green]") - input("Press Enter to continue...") - - def _test_mesh_source(self) -> None: - """Test a mesh data source connection.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to test.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Test Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to test", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - console.print(f"\n[dim]Testing {src.name} ({src.url})...[/dim]\n") - - try: - if src.type == "meshview": - from ..sources.meshview import MeshviewSource - source = MeshviewSource(url=src.url, refresh_interval=src.refresh_interval) - else: - from ..sources.meshmonitor_data import MeshMonitorDataSource - source = MeshMonitorDataSource( - url=src.url, - api_token=src.api_token, - refresh_interval=src.refresh_interval, - ) - - success = source.fetch_all() - - if success: - console.print("[green]Connection successful![/green]\n") - console.print(f" Nodes: {len(source.nodes)}") - if src.type == "meshview": - console.print(f" Edges: {len(source.edges)}") - console.print(f" Stats: {'loaded' if source.stats else 'none'}") - console.print(f" Counts: {'loaded' if source.counts else 'none'}") - else: - console.print(f" Channels: {len(source.channels)}") - console.print(f" Telemetry: {len(source.telemetry)}") - console.print(f" Traceroutes: {len(source.traceroutes)}") - console.print(f" Packets: {len(source.packets)}") - else: - console.print(f"[red]Connection failed: {source.last_error}[/red]") - - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - input("\nPress Enter to continue...") - - def _mesh_intelligence_settings(self) -> None: - """Mesh intelligence settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Intelligence Settings[/bold]\n") - console.print("[dim]Region-based health scoring for mesh analysis.[/dim]\n") - - mi = self.config.mesh_intelligence - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(mi.enabled)) - table.add_row("2", "Regions", f"{len(mi.regions)} defined" if mi.regions else "[dim]none[/dim]") - table.add_row("3", "Locality Radius (miles)", str(mi.locality_radius_miles)) - table.add_row("4", "Offline Threshold (hours)", str(mi.offline_threshold_hours)) - table.add_row("5", "Packet Threshold (24h)", str(mi.packet_threshold)) - table.add_row("6", "Battery Warning (%)", str(mi.battery_warning_percent)) - crit_nodes = getattr(mi, 'critical_nodes', []) - alert_ch = getattr(mi, 'alert_channel', -1) - alert_cd = getattr(mi, 'alert_cooldown_minutes', 30) - table.add_row("7", "Critical Nodes", ", ".join(crit_nodes) if crit_nodes else "[dim]none[/dim]") - table.add_row("8", "Alert Channel", f"Channel {alert_ch}" if alert_ch >= 0 else "[dim]disabled[/dim]") - table.add_row("9", "Alert Cooldown (min)", str(alert_cd)) - table.add_row("10", "Alert Rules", "Configure conditions") - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - mi.enabled = not mi.enabled - self.modified = True - elif choice == 2: - self._edit_regions() - elif choice == 3: - value = float(Prompt.ask("Locality radius (miles)", default=str(mi.locality_radius_miles))) - if value != mi.locality_radius_miles: - mi.locality_radius_miles = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Offline threshold (hours)", default=mi.offline_threshold_hours) - if value != mi.offline_threshold_hours: - mi.offline_threshold_hours = value - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Packet threshold (24h)", default=mi.packet_threshold) - if value != mi.packet_threshold: - mi.packet_threshold = value - self.modified = True - elif choice == 6: - value = IntPrompt.ask("Battery warning (%)", default=mi.battery_warning_percent) - if value != mi.battery_warning_percent: - mi.battery_warning_percent = value - self.modified = True - elif choice == 7: - self._edit_critical_nodes() - elif choice == 8: - ch = Prompt.ask("Alert channel (-1 to disable)", default=str(getattr(mi, 'alert_channel', -1))) - try: - new_ch = int(ch) - if new_ch != getattr(mi, 'alert_channel', -1): - mi.alert_channel = new_ch - self.modified = True - except ValueError: - pass - elif choice == 9: - mins = Prompt.ask("Alert cooldown (minutes)", default=str(getattr(mi, 'alert_cooldown_minutes', 30))) - try: - new_mins = int(mins) - if new_mins != getattr(mi, 'alert_cooldown_minutes', 30): - mi.alert_cooldown_minutes = new_mins - self.modified = True - except ValueError: - pass - elif choice == 10: - self._edit_alert_rules() - - def _edit_critical_nodes(self) -> None: - """Edit critical node list.""" - mi = self.config.mesh_intelligence - - while True: - self._clear() - console.print("[bold]Critical Nodes[/bold]") - console.print("[dim]These nodes trigger priority alerts when they go offline.[/dim]") - - crit_nodes = getattr(mi, 'critical_nodes', None) - if crit_nodes is None: - mi.critical_nodes = [] - crit_nodes = mi.critical_nodes - - if crit_nodes: - for i, name in enumerate(crit_nodes, 1): - console.print(f" {i}. {name}") - else: - console.print(" [dim]No critical nodes configured[/dim]") - - console.print("[cyan]A[/cyan] Add [cyan]R[/cyan] Remove [cyan]B[/cyan] Back") - choice = Prompt.ask("Choice", default="b").strip().lower() - - if choice == "b" or choice == "": - break - elif choice == "a": - name = Prompt.ask("Node short name (e.g., MHR)") - if name and name.strip(): - mi.critical_nodes.append(name.strip()) - self.modified = True - elif choice == "r": - if crit_nodes: - idx = Prompt.ask("Number to remove") - try: - idx = int(idx) - if 1 <= idx <= len(crit_nodes): - removed = mi.critical_nodes.pop(idx - 1) - console.print(f"Removed: {removed}") - self.modified = True - except (ValueError, IndexError): - pass - - def _edit_alert_rules(self) -> None: - """Edit per-condition alert toggles.""" - mi = self.config.mesh_intelligence - rules = mi.alert_rules - - while True: - self._clear() - console.print("[bold]Alert Rules[/bold]") - console.print("[dim]Toggle individual alert conditions on/off.[/dim]") - console.print() - - table = Table(box=box.ROUNDED) - table.add_column("#", style="cyan", width=3) - table.add_column("Category", style="white") - table.add_column("Condition", style="white") - table.add_column("Status", style="green") - - # Infrastructure - table.add_row("1", "Infra", "Router offline", self._status_icon(rules.infra_offline)) - table.add_row("2", "Infra", "Router recovery", self._status_icon(rules.infra_recovery)) - table.add_row("3", "Infra", "New router appears", self._status_icon(rules.new_router)) - - # Power - table.add_row("4", "Power", f"Battery warning (<{rules.battery_warning_threshold}%)", self._status_icon(rules.battery_warning)) - table.add_row("5", "Power", f"Battery critical (<{rules.battery_critical_threshold}%)", self._status_icon(rules.battery_critical)) - table.add_row("6", "Power", f"Battery emergency (<{rules.battery_emergency_threshold}%)", self._status_icon(rules.battery_emergency)) - table.add_row("7", "Power", "7-day declining trend", self._status_icon(rules.battery_trend_declining)) - table.add_row("8", "Power", "USB to battery (power outage)", self._status_icon(rules.power_source_change)) - table.add_row("9", "Power", "Solar not charging", self._status_icon(rules.solar_not_charging)) - - # Utilization - table.add_row("10", "Util", f"Sustained >{rules.high_util_threshold}% for {rules.high_util_hours}h", self._status_icon(rules.sustained_high_util)) - table.add_row("11", "Util", f"Packet flood (>{rules.packet_flood_threshold}/24h)", self._status_icon(rules.packet_flood)) - - # Coverage - table.add_row("12", "Coverage", "Infra drops to 1 gateway", self._status_icon(rules.infra_single_gateway)) - table.add_row("13", "Coverage", "Feeder gateway offline", self._status_icon(rules.feeder_offline)) - table.add_row("14", "Coverage", "Region total blackout", self._status_icon(rules.region_total_blackout)) - - # Health scores - table.add_row("15", "Scores", f"Mesh score <{rules.mesh_score_threshold}", self._status_icon(rules.mesh_score_alert)) - table.add_row("16", "Scores", f"Region score <{rules.region_score_threshold}", self._status_icon(rules.region_score_alert)) - - table.add_row("", "", "", "") - table.add_row("0", "", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Toggle option (0 to go back)", default=0) - - if choice == 0: - return - - # Map choice to field toggle - toggles = { - 1: "infra_offline", - 2: "infra_recovery", - 3: "new_router", - 4: "battery_warning", - 5: "battery_critical", - 6: "battery_emergency", - 7: "battery_trend_declining", - 8: "power_source_change", - 9: "solar_not_charging", - 10: "sustained_high_util", - 11: "packet_flood", - 12: "infra_single_gateway", - 13: "feeder_offline", - 14: "region_total_blackout", - 15: "mesh_score_alert", - 16: "region_score_alert", - } - - field = toggles.get(choice) - if field and hasattr(rules, field): - current = getattr(rules, field) - setattr(rules, field, not current) - self.modified = True - - def _edit_regions(self) -> None: - """Edit region anchor points.""" - from ..config import RegionAnchor - - while True: - self._clear() - console.print("[bold]Region Anchors[/bold]\n") - console.print("[dim]Define region center points. Nodes are assigned to nearest region.[/dim]\n") - - regions = self.config.mesh_intelligence.regions - - if regions: - table = Table(box=box.ROUNDED) - table.add_column("#", style="cyan", width=3) - table.add_column("Name", style="white") - table.add_column("Latitude", style="green") - table.add_column("Longitude", style="green") - - for i, r in enumerate(regions, 1): - table.add_row(str(i), r.name, f"{r.lat:.4f}", f"{r.lon:.4f}") - console.print(table) - else: - console.print("[dim]No regions defined.[/dim]") - - console.print() - console.print("[cyan]1.[/cyan] Add region") - console.print("[cyan]2.[/cyan] Edit region") - console.print("[cyan]3.[/cyan] Remove region") - console.print("[cyan]4.[/cyan] Load ID/UT defaults") - console.print("[cyan]0.[/cyan] Back") - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - name = Prompt.ask("Region name") - if name: - lat = float(Prompt.ask("Center latitude", default="0.0")) - lon = float(Prompt.ask("Center longitude", default="0.0")) - self.config.mesh_intelligence.regions.append( - RegionAnchor(name=name, lat=lat, lon=lon) - ) - self.modified = True - console.print(f"[green]Added: {name} @ {lat}, {lon}[/green]") - input("Press Enter to continue...") - elif choice == 2: - if not regions: - console.print("[yellow]No regions to edit.[/yellow]") - input("\nPress Enter to continue...") - continue - console.print() - for i, r in enumerate(regions, 1): - console.print(f"[cyan]{i}.[/cyan] {r.name}") - console.print("[cyan]0.[/cyan] Cancel") - idx = IntPrompt.ask("Select region", default=0) - if 1 <= idx <= len(regions): - r = regions[idx - 1] - r.name = Prompt.ask("Name", default=r.name) - r.lat = float(Prompt.ask("Latitude", default=str(r.lat))) - r.lon = float(Prompt.ask("Longitude", default=str(r.lon))) - self.modified = True - elif choice == 3: - if not regions: - console.print("[yellow]No regions to remove.[/yellow]") - input("\nPress Enter to continue...") - continue - console.print() - for i, r in enumerate(regions, 1): - console.print(f"[cyan]{i}.[/cyan] {r.name}") - console.print("[cyan]0.[/cyan] Cancel") - idx = IntPrompt.ask("Select region to remove", default=0) - if 1 <= idx <= len(regions): - removed = self.config.mesh_intelligence.regions.pop(idx - 1) - self.modified = True - console.print(f"[green]Removed: {removed.name}[/green]") - input("Press Enter to continue...") - elif choice == 4: - # Load ID/UT region defaults - self.config.mesh_intelligence.regions = [ - # Idaho - RegionAnchor(name="Northern ID", lat=47.6777, lon=-116.7805), # Coeur d'Alene - RegionAnchor(name="Central ID", lat=45.1757, lon=-113.8958), # Salmon - RegionAnchor(name="South Western ID", lat=43.6150, lon=-116.2023), # Boise - RegionAnchor(name="South Central ID", lat=42.5558, lon=-114.4701), # Twin Falls - RegionAnchor(name="South Eastern ID", lat=43.4926, lon=-112.0341), # Idaho Falls - # Utah - RegionAnchor(name="Northern UT", lat=41.2230, lon=-111.9738), # Ogden - RegionAnchor(name="Central UT", lat=40.7608, lon=-111.8910), # Salt Lake City - RegionAnchor(name="Eastern UT", lat=40.4555, lon=-109.5287), # Vernal - RegionAnchor(name="Western UT", lat=40.5308, lon=-112.2983), # Tooele - RegionAnchor(name="Southern UT", lat=37.0965, lon=-113.5684), # St. George - ] - self.modified = True - console.print("[green]Loaded 10 ID/UT region defaults.[/green]") - input("Press Enter to continue...") - - def _setup_wizard(self) -> None: - """First-time setup wizard.""" - self._clear() - console.print(Panel("[bold]MeshAI Setup Wizard[/bold]", style="cyan")) - console.print("\nThis wizard will help you configure MeshAI.\n") - - # Step 1: Bot identity - console.print("[bold cyan]Step 1: Bot Identity[/bold cyan]") - self.config.bot.name = Prompt.ask("Bot name", default="ai") - self.config.bot.owner = Prompt.ask("Your name/callsign", default="") - console.print() - - # Step 2: Connection - console.print("[bold cyan]Step 2: Meshtastic Connection[/bold cyan]") - console.print("[cyan]1.[/cyan] serial - USB Serial") - console.print("[cyan]2.[/cyan] tcp - Network TCP") - sel = IntPrompt.ask("Connection type", default=1) - self.config.connection.type = "serial" if sel == 1 else "tcp" - - if self.config.connection.type == "serial": - self.config.connection.serial_port = Prompt.ask( - "Serial port", default="/dev/ttyUSB0" - ) - else: - self.config.connection.tcp_host = Prompt.ask( - "TCP host", default="192.168.1.100" - ) - self.config.connection.tcp_port = IntPrompt.ask("TCP port", default=4403) - console.print() - - # Step 3: LLM - console.print("[bold cyan]Step 3: LLM Backend[/bold cyan]") - console.print("[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible") - console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") - console.print("[cyan]3.[/cyan] google - Google Gemini") - sel = IntPrompt.ask("Backend", default=1) - backends = {1: "openai", 2: "anthropic", 3: "google"} - self.config.llm.backend = backends.get(sel, "openai") - - self.config.llm.api_key = Prompt.ask("API Key", password=True) - - if self.config.llm.backend == "openai": - if Confirm.ask("Using local/self-hosted API?", default=False): - self.config.llm.base_url = Prompt.ask( - "Base URL", default="http://localhost:4000/v1" - ) - - self.config.llm.model = Prompt.ask("Model", default="gpt-4o-mini") - console.print() - - # Step 4: Weather (optional) - console.print("[bold cyan]Step 4: Weather (optional)[/bold cyan]") - self.config.weather.default_location = Prompt.ask( - "Default location (for !weather)", default="" - ) - console.print() - - self.modified = True - console.print("[green]Setup complete![/green]") - console.print("Press Enter to return to main menu...") - input() - - def _save_only(self) -> None: - """Save config and stay in menu.""" - save_config(self.config, self.config_path) - console.print(f"[green]Configuration saved to {self.config_path}[/green]") - self.modified = False - input("Press Enter to continue...") - - def _save_and_restart(self) -> None: - """Save config and signal bot to restart, stay in menu.""" - self._clear() - console.print("[cyan]Saving configuration...[/cyan]") - save_config(self.config, self.config_path) - console.print("[green]Configuration saved![/green]") - self.modified = False - console.print() - - # Write restart signal file (docker-entrypoint watches for this) - restart_file = Path("/tmp/meshai_restart") - try: - restart_file.touch() - console.print("[cyan]Bot restart signal sent.[/cyan]") - console.print() - console.print("The bot will restart momentarily to apply changes.") - except Exception as e: - console.print(f"[yellow]Could not signal restart: {e}[/yellow]") - - input("\nPress Enter to continue...") - - def _save_restart_exit(self) -> None: - """Save config, signal bot restart, and exit config tool.""" - console.print("[cyan]Saving configuration...[/cyan]") - save_config(self.config, self.config_path) - console.print("[green]Configuration saved![/green]") - self.modified = False - - # Write restart signal file - restart_file = Path("/tmp/meshai_restart") - try: - restart_file.touch() - console.print("[cyan]Bot restart signal sent.[/cyan]") - except Exception as e: - console.print(f"[yellow]Could not signal restart: {e}[/yellow]") - - console.print("\nGoodbye!") - - -def run_configurator(config_path: Optional[Path] = None) -> None: - """Entry point for configurator.""" - configurator = Configurator(config_path) - configurator.run() diff --git a/work/meshai/config.py b/work/meshai/config.py index 56ab8d9..e35cba8 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -1090,7 +1090,7 @@ def save_config(config: Config, config_path: Optional[Path] = None) -> None: data = _dataclass_to_dict(config) # Add header comment - header = "# MeshAI Configuration\n# Generated by meshai --config\n\n" + header = "# MeshAI Configuration\n# Generated by meshai\n\n" with open(config_path, "w") as f: f.write(header) diff --git a/work/meshai/main.py b/work/meshai/main.py index 1de6bc7..a54d448 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -12,7 +12,6 @@ from typing import Optional from . import __version__ from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend -from .cli import run_configurator from .commands import CommandDispatcher from .commands.dispatcher import create_dispatcher from .commands.status import set_start_time @@ -876,9 +875,6 @@ def main() -> None: parser.add_argument( "--version", "-V", action="version", version=f"%(prog)s {__version__}" ) - parser.add_argument( - "--config", "-c", action="store_true", help="Launch configuration tool" - ) parser.add_argument( "--config-file", "-f", @@ -892,11 +888,6 @@ def main() -> None: setup_logging(args.verbose) - # Launch configurator if requested - if args.config: - run_configurator(args.config_file) - return - # Load config - support both old (/data/config.yaml) and new (/data/config/) layouts config_path = args.config_file config_dir = get_config_dir_from_path(config_path) @@ -912,7 +903,7 @@ def main() -> None: config = legacy_load(config_path) else: logger.warning(f"Config not found at {config_path} or {config_dir}") - logger.info("Run 'meshai --config' to create one, or copy config.example.yaml") + logger.info("Copy config.example.yaml to get started, or configure via the dashboard") sys.exit(1) # Create and run bot diff --git a/work/pyproject.toml b/work/pyproject.toml index 3b542e5..7220daf 100644 --- a/work/pyproject.toml +++ b/work/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ "openai>=1.0.0", "anthropic>=0.18.0", "google-genai>=1.0.0", - "rich>=13.0.0", "httpx>=0.25.0", "fastapi>=0.110.0", "uvicorn[standard]>=0.27.0", diff --git a/work/requirements.txt b/work/requirements.txt index 091bb36..9a24c23 100644 --- a/work/requirements.txt +++ b/work/requirements.txt @@ -5,7 +5,6 @@ aiosqlite>=0.19.0 openai>=1.0.0 anthropic>=0.18.0 google-genai>=1.0.0 -rich>=13.0.0 httpx>=0.25.0 fastembed>=0.3.0 sqlite-vec>=0.1.0