"""Companion Web Server for VoiceAgent. Provides a real-time web UI dashboard at http://localhost:8888 showing: - Real-time conversation stream (User utterances & Assistant replies) - Grouped tool executions and assistant responses in unified turn cards - Text input box for typing commands directly to the agent - Active AI Model (OpenAI, OpenCode, Claude, MLX) and Voice configuration controls - Workspace file viewer for inspecting referenced codebase files """ import asyncio import json import os from datetime import datetime from pathlib import Path from typing import Set, Callable import aiohttp from aiohttp import web from loguru import logger # Active SSE client queues _sse_clients: Set[asyncio.Queue] = set() _workspace_dir: Path = Path(__file__).parent.resolve() _model_manager = None _voice_manager = None _input_callback: Callable = None _recent_events: list = [] _MAX_HISTORY_EVENTS = 200 def set_managers(workspace: Path, model_mgr=None, voice_mgr=None, input_callback=None): global _workspace_dir, _model_manager, _voice_manager, _input_callback _workspace_dir = Path(workspace) _model_manager = model_mgr _voice_manager = voice_mgr if input_callback is not None: _input_callback = input_callback def broadcast_event(event_type: str, payload: dict): """Broadcast an event to all connected web clients.""" event_data = { "type": event_type, "at": datetime.now().isoformat(timespec="seconds"), **payload, } _recent_events.append(event_data) if len(_recent_events) > _MAX_HISTORY_EVENTS: _recent_events.pop(0) dead_clients = set() for q in _sse_clients: try: q.put_nowait(event_data) except Exception: dead_clients.add(q) for q in dead_clients: _sse_clients.discard(q) async def handle_index(request): return web.Response(text=HTML_INDEX, content_type="text/html") async def handle_sse(request): response = web.StreamResponse( status=200, reason="OK", headers={ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", }, ) await response.prepare(request) q = asyncio.Queue() _sse_clients.add(q) model = _model_manager._active_model if _model_manager else "Default" voice = _voice_manager._active_voice if _voice_manager else "af_heart" init_msg = { "type": "init", "workspace": str(_workspace_dir), "model": model, "voice": voice, "history": _recent_events[-50:], } await response.write(f"data: {json.dumps(init_msg)}\n\n".encode("utf-8")) try: while True: event_data = await q.get() data_str = json.dumps(event_data) await response.write(f"data: {data_str}\n\n".encode("utf-8")) except (asyncio.CancelledError, ConnectionResetError): pass finally: _sse_clients.discard(q) return response async def handle_history(request): journal_file = _workspace_dir / "journal.jsonl" turns = [] if journal_file.exists(): try: lines = journal_file.read_text().splitlines() for line in lines[-100:]: if line.strip(): turns.append(json.loads(line)) except Exception as e: logger.debug(f"Could not read journal file: {e}") return web.json_response({"turns": turns, "recent_events": _recent_events[-50:]}) async def handle_file(request): rel_path = request.query.get("path", "") if not rel_path: return web.json_response({"error": "Missing path parameter"}, status=400) target_path = Path(rel_path) if not target_path.is_absolute(): target_path = (_workspace_dir / rel_path).resolve() if not target_path.exists() or target_path.is_dir(): return web.json_response({"error": f"File not found: {rel_path}"}, status=404) try: content = target_path.read_text(encoding="utf-8", errors="replace") return web.json_response({ "path": str(target_path), "name": target_path.name, "content": content[:100000], "size": len(content), }) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def handle_get_models(request): if _model_manager: return web.json_response({ "active": _model_manager._active_model, "categories": _model_manager.get_models_dict(), }) return web.json_response({"active": "Default", "categories": {}}) async def handle_set_model(request): try: body = await request.json() model_name = body.get("model") if not model_name: return web.json_response({"error": "Missing model"}, status=400) if _model_manager: ok, msg = _model_manager.apply_model(model_name) if ok: broadcast_event("status_change", {"model": _model_manager._active_model}) return web.json_response({"success": True, "message": msg, "model": _model_manager._active_model}) return web.json_response({"error": msg}, status=400) return web.json_response({"error": "Model manager not active"}, status=500) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def handle_set_voice(request): try: body = await request.json() voice_name = body.get("voice") if not voice_name: return web.json_response({"error": "Missing voice"}, status=400) if _voice_manager: ok, msg = _voice_manager.apply_voice(voice_name) if ok: broadcast_event("status_change", {"voice": _voice_manager._active_voice}) return web.json_response({"success": True, "message": msg, "voice": _voice_manager._active_voice}) return web.json_response({"error": msg}, status=400) return web.json_response({"error": "Voice manager not active"}, status=500) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def handle_show_file_api(request): try: body = await request.json() rel_path = body.get("path", "") if not rel_path: return web.json_response({"error": "Missing path parameter"}, status=400) target_path = Path(rel_path) if not target_path.is_absolute(): target_path = (_workspace_dir / rel_path).resolve() if not target_path.exists() or target_path.is_dir(): return web.json_response({"error": f"File not found: {rel_path}"}, status=404) content = target_path.read_text(encoding="utf-8", errors="replace")[:100000] broadcast_event("show_file", { "path": str(target_path), "name": target_path.name, "content": content, }) return web.json_response({"success": True, "path": str(target_path)}) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def handle_open_browser_api(request): try: body = await request.json() url = body.get("url", "http://localhost:8888") if not url.startswith("http://") and not url.startswith("https://"): url = "http://" + url os.system(f'open "{url}"') return web.json_response({"success": True, "url": url}) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def handle_send_message_api(request): try: body = await request.json() text = body.get("text", "").strip() if not text: return web.json_response({"error": "Message text cannot be empty"}, status=400) if _input_callback: if asyncio.iscoroutinefunction(_input_callback): await _input_callback(text) else: _input_callback(text) return web.json_response({"success": True, "text": text}) else: broadcast_event("heard", {"text": text}) return web.json_response({"success": True, "text": text}) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def start_server(workspace: Path, host: str = "127.0.0.1", port: int = 8888): set_managers(workspace) app = web.Application() app.router.add_get("/", handle_index) app.router.add_get("/api/stream", handle_sse) app.router.add_get("/api/history", handle_history) app.router.add_get("/api/file", handle_file) app.router.add_get("/api/models", handle_get_models) app.router.add_post("/api/model", handle_set_model) app.router.add_post("/api/voice", handle_set_voice) app.router.add_post("/api/show_file", handle_show_file_api) app.router.add_post("/api/open_browser", handle_open_browser_api) app.router.add_post("/api/send", handle_send_message_api) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, host, port) try: await site.start() logger.info(f"Companion Web Chat UI server active at http://{host}:{port}") except Exception as e: logger.warning(f"Could not start Companion Web Server on port {port}: {e}") # Embedded HTML Frontend UI HTML_INDEX = """ VoiceAgent Companion

VoiceAgent Companion

Model: Loading...
Voice: Loading...
👋 Welcome! VoiceAgent Companion is active. Speech audio, turns, typed commands, and real-time tool executions will appear here live.
System • Ready

File Viewer

Select a file to inspect.
"""