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
This commit is contained in:
207
adapters/webchat_adapter.py
Normal file
207
adapters/webchat_adapter.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
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)"
|
||||
)
|
||||
Reference in New Issue
Block a user