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
208 lines
7.4 KiB
Python
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)"
|
|
)
|