Files
Aetheel/adapters/webchat_adapter.py
tanmay11k 6d73f74e0b feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover
Major changes:
- Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required
- Runtime engine field: runtime.engine selects opencode/claude from config
- Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd)
- Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json
- Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command
- Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification
- Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help
- /config set for editing config.json from chat with dotted key notation
- Security audit saved to docs/security-audit.md
- Full command reference in docs/commands.md
- Future changes doc with NanoClaw agent teams analysis
- Logo added to README and WebChat UI
- README fully rewritten with all features documented
2026-02-18 01:07:12 -05:00

208 lines
7.4 KiB
Python

"""
Aetheel WebChat Adapter
========================
Browser-based chat interface using aiohttp HTTP + WebSocket.
Features:
- Serves a self-contained chat UI at GET /
- WebSocket endpoint at /ws for real-time messaging
- Per-connection session isolation with unique conversation IDs
- Runs sync ai_handler in thread pool executor
- Supports multiple concurrent WebSocket connections
Setup:
1. Enable webchat in config.json: {"webchat": {"enabled": true}}
2. Start with: python main.py --webchat
3. Open http://127.0.0.1:8080 in your browser
Usage:
from adapters.webchat_adapter import WebChatAdapter
adapter = WebChatAdapter(host="127.0.0.1", port=8080)
adapter.on_message(my_handler)
adapter.start()
"""
import asyncio
import logging
import os
import threading
import uuid
import aiohttp
from aiohttp import web
from adapters.base import BaseAdapter, IncomingMessage
logger = logging.getLogger("aetheel.adapters.webchat")
class WebChatAdapter(BaseAdapter):
"""
WebChat adapter serving an HTTP/WebSocket interface for browser-based chat.
Each WebSocket connection gets a unique session ID and conversation ID,
ensuring full session isolation between concurrent users.
"""
def __init__(self, host: str = "127.0.0.1", port: int = 8080):
super().__init__()
self._host = host
self._port = port
self._app = web.Application()
self._sessions: dict[str, str] = {} # ws_id -> conversation_id
self._runner: web.AppRunner | None = None
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._setup_routes()
# -------------------------------------------------------------------
# Route setup
# -------------------------------------------------------------------
def _setup_routes(self) -> None:
"""Register HTTP and WebSocket routes."""
self._app.router.add_get("/", self._serve_html)
self._app.router.add_get("/logo.jpeg", self._serve_logo)
self._app.router.add_get("/ws", self._handle_websocket)
# -------------------------------------------------------------------
# HTTP handler
# -------------------------------------------------------------------
async def _serve_html(self, request: web.Request) -> web.Response:
"""Serve the chat UI HTML file."""
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
html_path = os.path.join(static_dir, "chat.html")
if os.path.isfile(html_path):
return web.FileResponse(html_path)
return web.Response(text="Chat UI not found", status=404)
async def _serve_logo(self, request: web.Request) -> web.Response:
"""Serve the logo image."""
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
logo_path = os.path.join(static_dir, "logo.jpeg")
if os.path.isfile(logo_path):
return web.FileResponse(logo_path)
return web.Response(status=404)
# -------------------------------------------------------------------
# WebSocket handler
# -------------------------------------------------------------------
async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
"""Handle a WebSocket connection with per-session isolation."""
ws = web.WebSocketResponse()
await ws.prepare(request)
session_id = uuid.uuid4().hex
self._sessions[session_id] = f"webchat-{session_id}"
logger.info(f"WebChat session connected: {session_id}")
try:
async for ws_msg in ws:
if ws_msg.type == aiohttp.WSMsgType.TEXT:
incoming = IncomingMessage(
text=ws_msg.data,
user_id=session_id,
user_name="WebChat User",
channel_id=session_id,
channel_name="webchat",
conversation_id=f"webchat-{session_id}",
source="webchat",
is_dm=True,
raw_event={"session_id": session_id},
)
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None, self._run_handler, incoming
)
if response:
await ws.send_str(response)
elif ws_msg.type in (
aiohttp.WSMsgType.ERROR,
aiohttp.WSMsgType.CLOSE,
):
break
finally:
self._sessions.pop(session_id, None)
logger.info(f"WebChat session disconnected: {session_id}")
return ws
def _run_handler(self, msg: IncomingMessage) -> str | None:
"""Run registered message handlers synchronously (called from executor)."""
for handler in self._message_handlers:
try:
response = handler(msg)
if response:
return response
except Exception as e:
logger.error(f"WebChat handler error: {e}", exc_info=True)
return "⚠️ Something went wrong processing your message."
return None
# -------------------------------------------------------------------
# BaseAdapter implementation
# -------------------------------------------------------------------
@property
def source_name(self) -> str:
return "webchat"
def start(self) -> None:
"""Start the WebChat server (blocking)."""
asyncio.run(self._run_server())
def start_async(self) -> None:
"""Start the WebChat server in a background thread (non-blocking)."""
self._thread = threading.Thread(
target=self._run_async, daemon=True, name="webchat"
)
self._thread.start()
def _run_async(self) -> None:
"""Run the server in a dedicated thread with its own event loop."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._run_server())
async def _run_server(self) -> None:
"""Set up and run the aiohttp server."""
self._runner = web.AppRunner(self._app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
logger.info(f"WebChat server running at http://{self._host}:{self._port}")
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
pass
def stop(self) -> None:
"""Stop the WebChat server gracefully."""
if self._runner:
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self._runner.cleanup(), self._loop
)
logger.info("WebChat server stopped")
def send_message(
self,
channel_id: str,
text: str,
thread_id: str | None = None,
) -> None:
"""
Send a message to a channel.
For WebChat, responses are sent directly over the WebSocket in the
handler, so this method only logs a debug message.
"""
logger.debug(
f"WebChat send_message called for {channel_id} "
"(responses sent via WebSocket)"
)