405 lines
13 KiB
Python
405 lines
13 KiB
Python
"""CLI commands for nanobot."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from nanobot import __version__, __logo__
|
|
|
|
app = typer.Typer(
|
|
name="nanobot",
|
|
help=f"{__logo__} nanobot - Personal AI Assistant",
|
|
no_args_is_help=True,
|
|
)
|
|
|
|
console = Console()
|
|
|
|
|
|
def version_callback(value: bool):
|
|
if value:
|
|
console.print(f"{__logo__} nanobot v{__version__}")
|
|
raise typer.Exit()
|
|
|
|
|
|
@app.callback()
|
|
def main(
|
|
version: bool = typer.Option(
|
|
None, "--version", "-v", callback=version_callback, is_eager=True
|
|
),
|
|
):
|
|
"""nanobot - Personal AI Assistant."""
|
|
pass
|
|
|
|
|
|
# ============================================================================
|
|
# Onboard / Setup
|
|
# ============================================================================
|
|
|
|
|
|
@app.command()
|
|
def onboard():
|
|
"""Initialize nanobot configuration and workspace."""
|
|
from nanobot.config.loader import get_config_path, save_config
|
|
from nanobot.config.schema import Config
|
|
from nanobot.utils.helpers import get_workspace_path
|
|
|
|
config_path = get_config_path()
|
|
|
|
if config_path.exists():
|
|
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
|
if not typer.confirm("Overwrite?"):
|
|
raise typer.Exit()
|
|
|
|
# Create default config
|
|
config = Config()
|
|
save_config(config)
|
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
|
|
|
# Create workspace
|
|
workspace = get_workspace_path()
|
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
|
|
|
# Create default bootstrap files
|
|
_create_workspace_templates(workspace)
|
|
|
|
console.print(f"\n{__logo__} nanobot is ready!")
|
|
console.print("\nNext steps:")
|
|
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
|
console.print(" Get one at: https://openrouter.ai/keys or https://bigmodel.cn/ (Zhipu AI)")
|
|
console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
|
|
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
|
|
|
|
|
|
|
|
|
def _create_workspace_templates(workspace: Path):
|
|
"""Create default workspace template files."""
|
|
templates = {
|
|
"AGENTS.md": """# Agent Instructions
|
|
|
|
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|
|
|
## Guidelines
|
|
|
|
- Always explain what you're doing before taking actions
|
|
- Ask for clarification when the request is ambiguous
|
|
- Use tools to help accomplish tasks
|
|
- Remember important information in your memory files
|
|
""",
|
|
"SOUL.md": """# Soul
|
|
|
|
I am nanobot, a lightweight AI assistant.
|
|
|
|
## Personality
|
|
|
|
- Helpful and friendly
|
|
- Concise and to the point
|
|
- Curious and eager to learn
|
|
|
|
## Values
|
|
|
|
- Accuracy over speed
|
|
- User privacy and safety
|
|
- Transparency in actions
|
|
""",
|
|
"USER.md": """# User
|
|
|
|
Information about the user goes here.
|
|
|
|
## Preferences
|
|
|
|
- Communication style: (casual/formal)
|
|
- Timezone: (your timezone)
|
|
- Language: (your preferred language)
|
|
""",
|
|
}
|
|
|
|
for filename, content in templates.items():
|
|
file_path = workspace / filename
|
|
if not file_path.exists():
|
|
file_path.write_text(content)
|
|
console.print(f" [dim]Created {filename}[/dim]")
|
|
|
|
# Create memory directory and MEMORY.md
|
|
memory_dir = workspace / "memory"
|
|
memory_dir.mkdir(exist_ok=True)
|
|
memory_file = memory_dir / "MEMORY.md"
|
|
if not memory_file.exists():
|
|
memory_file.write_text("""# Long-term Memory
|
|
|
|
This file stores important information that should persist across sessions.
|
|
|
|
## User Information
|
|
|
|
(Important facts about the user)
|
|
|
|
## Preferences
|
|
|
|
(User preferences learned over time)
|
|
|
|
## Important Notes
|
|
|
|
(Things to remember)
|
|
""")
|
|
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
|
|
|
|
|
# ============================================================================
|
|
# Gateway / Server
|
|
# ============================================================================
|
|
|
|
|
|
@app.command()
|
|
def gateway(
|
|
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
):
|
|
"""Start the nanobot gateway."""
|
|
from nanobot.config.loader import load_config, get_data_dir
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.channels.manager import ChannelManager
|
|
from nanobot.cron.service import CronService
|
|
from nanobot.cron.types import CronJob
|
|
from nanobot.heartbeat.service import HeartbeatService
|
|
|
|
if verbose:
|
|
import logging
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
|
|
|
config = load_config()
|
|
|
|
# Create components
|
|
bus = MessageBus()
|
|
|
|
# Create provider (supports OpenRouter, Anthropic, OpenAI)
|
|
api_key = config.get_api_key()
|
|
api_base = config.get_api_base()
|
|
|
|
if not api_key:
|
|
console.print("[red]Error: No API key configured.[/red]")
|
|
console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey")
|
|
raise typer.Exit(1)
|
|
|
|
provider = LiteLLMProvider(
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
default_model=config.agents.defaults.model
|
|
)
|
|
|
|
# Create agent
|
|
agent = AgentLoop(
|
|
bus=bus,
|
|
provider=provider,
|
|
workspace=config.workspace_path,
|
|
model=config.agents.defaults.model,
|
|
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
brave_api_key=config.tools.web.search.api_key or None
|
|
)
|
|
|
|
# Create cron service
|
|
async def on_cron_job(job: CronJob) -> str | None:
|
|
"""Execute a cron job through the agent."""
|
|
response = await agent.process_direct(
|
|
job.payload.message,
|
|
session_key=f"cron:{job.id}"
|
|
)
|
|
# Optionally deliver to channel
|
|
if job.payload.deliver and job.payload.to:
|
|
from nanobot.bus.events import OutboundMessage
|
|
await bus.publish_outbound(OutboundMessage(
|
|
channel=job.payload.channel or "whatsapp",
|
|
chat_id=job.payload.to,
|
|
content=response or ""
|
|
))
|
|
return response
|
|
|
|
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
|
cron = CronService(cron_store_path, on_job=on_cron_job)
|
|
|
|
# Create heartbeat service
|
|
async def on_heartbeat(prompt: str) -> str:
|
|
"""Execute heartbeat through the agent."""
|
|
return await agent.process_direct(prompt, session_key="heartbeat")
|
|
|
|
heartbeat = HeartbeatService(
|
|
workspace=config.workspace_path,
|
|
on_heartbeat=on_heartbeat,
|
|
interval_s=30 * 60, # 30 minutes
|
|
enabled=True
|
|
)
|
|
|
|
# Create channel manager
|
|
channels = ChannelManager(config, bus)
|
|
|
|
if channels.enabled_channels:
|
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
|
else:
|
|
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
|
|
|
cron_status = cron.status()
|
|
if cron_status["jobs"] > 0:
|
|
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
|
|
|
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
|
|
|
async def run():
|
|
try:
|
|
await cron.start()
|
|
await heartbeat.start()
|
|
await asyncio.gather(
|
|
agent.run(),
|
|
channels.start_all(),
|
|
)
|
|
except KeyboardInterrupt:
|
|
console.print("\nShutting down...")
|
|
heartbeat.stop()
|
|
cron.stop()
|
|
agent.stop()
|
|
await channels.stop_all()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# Agent Commands
|
|
# ============================================================================
|
|
|
|
|
|
@app.command()
|
|
def agent(
|
|
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
|
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
|
|
):
|
|
"""Interact with the agent directly."""
|
|
from nanobot.config.loader import load_config
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
|
from nanobot.agent.loop import AgentLoop
|
|
|
|
config = load_config()
|
|
|
|
api_key = config.get_api_key()
|
|
api_base = config.get_api_base()
|
|
|
|
if not api_key:
|
|
console.print("[red]Error: No API key configured.[/red]")
|
|
raise typer.Exit(1)
|
|
|
|
bus = MessageBus()
|
|
provider = LiteLLMProvider(
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
default_model=config.agents.defaults.model
|
|
)
|
|
|
|
agent_loop = AgentLoop(
|
|
bus=bus,
|
|
provider=provider,
|
|
workspace=config.workspace_path,
|
|
brave_api_key=config.tools.web.search.api_key or None
|
|
)
|
|
|
|
if message:
|
|
# Single message mode
|
|
async def run_once():
|
|
response = await agent_loop.process_direct(message, session_id)
|
|
console.print(f"\n{__logo__} {response}")
|
|
|
|
asyncio.run(run_once())
|
|
else:
|
|
# Interactive mode
|
|
console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
|
|
|
|
async def run_interactive():
|
|
while True:
|
|
try:
|
|
user_input = console.input("[bold blue]You:[/bold blue] ")
|
|
if not user_input.strip():
|
|
continue
|
|
|
|
response = await agent_loop.process_direct(user_input, session_id)
|
|
console.print(f"\n{__logo__} {response}\n")
|
|
except (KeyboardInterrupt, EOFError):
|
|
console.print("\nExiting...")
|
|
break
|
|
|
|
asyncio.run(run_interactive())
|
|
|
|
|
|
# ============================================================================
|
|
# System Commands
|
|
# ============================================================================
|
|
|
|
|
|
@app.command()
|
|
def status():
|
|
"""Check nanobot status and configuration."""
|
|
from nanobot.config.loader import load_config, get_config_path
|
|
|
|
config_path = get_config_path()
|
|
if not config_path.exists():
|
|
console.print("[red]Error: nanobot is not initialized.[/red]")
|
|
console.print("Run [cyan]nanobot onboard[/cyan] first.")
|
|
raise typer.Exit(1)
|
|
|
|
config = load_config()
|
|
|
|
console.print(f"{__logo__} [bold]nanobot status[/bold]")
|
|
console.print(f"Version: {__version__}")
|
|
console.print(f"Config: {config_path}")
|
|
console.print(f"Workspace: {config.workspace_path}")
|
|
|
|
table = Table(title="Configuration Summary")
|
|
table.add_column("Category", style="cyan")
|
|
table.add_column("Status", style="green")
|
|
|
|
# Channels
|
|
enabled_channels = []
|
|
if config.channels.whatsapp.enabled:
|
|
enabled_channels.append("WhatsApp")
|
|
if config.channels.telegram.enabled:
|
|
enabled_channels.append("Telegram")
|
|
|
|
table.add_row("Channels", ", ".join(enabled_channels) if enabled_channels else "[dim]none[/dim]")
|
|
|
|
# Agent
|
|
table.add_row("Default Model", config.agents.defaults.model)
|
|
|
|
# Tools
|
|
has_brave = bool(config.tools.web.search.api_key)
|
|
table.add_row("Web Search", "[green]enabled[/green]" if has_brave else "[dim]disabled[/dim]")
|
|
|
|
console.print(table)
|
|
|
|
# Detailed API check
|
|
with console.status("[bold blue]Checking API providers..."):
|
|
console.print(f"\n[bold]API Providers:[/bold]")
|
|
console.print(f"Model: {config.agents.defaults.model}")
|
|
|
|
# Check API keys
|
|
has_openrouter = bool(config.providers.openrouter.api_key)
|
|
has_anthropic = bool(config.providers.anthropic.api_key)
|
|
has_openai = bool(config.providers.openai.api_key)
|
|
has_zhipu = bool(config.providers.zhipu.api_key)
|
|
has_vllm = bool(config.providers.vllm.api_base)
|
|
|
|
console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}")
|
|
console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
|
|
console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
|
|
console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}")
|
|
vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
|
|
console.print(f"vLLM/Local: {vllm_status}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|