Files
VoiceAgent/web_server.py
T

1135 lines
43 KiB
Python

"""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
_audio_controller = 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, audio_controller=None):
global _workspace_dir, _model_manager, _voice_manager, _input_callback, _audio_controller
_workspace_dir = Path(workspace)
_model_manager = model_mgr
_voice_manager = voice_mgr
if input_callback is not None:
_input_callback = input_callback
if audio_controller is not None:
_audio_controller = audio_controller
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_reset_session(request):
try:
from bin.session_tool import reset_session
ok, msg = reset_session(_workspace_dir)
if ok:
broadcast_event("session_reset", {"message": msg})
return web.json_response({"success": True, "message": msg})
return web.json_response({"error": msg}, status=500)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_get_audio_devices(request):
if not _audio_controller:
return web.json_response({"error": "Audio controller not active"}, status=503)
return web.json_response({"devices": _audio_controller.available_devices()})
async def handle_set_audio_device(request):
try:
if not _audio_controller:
return web.json_response({"error": "Audio controller not active"}, status=503)
body = await request.json()
direction, device = body.get("direction"), body.get("device")
if direction not in {"input", "output"}:
return web.json_response({"error": "direction must be input or output"}, status=400)
result = await _audio_controller.set_runtime_device(direction, device)
broadcast_event("audio_device_selected", result)
return web.json_response({"success": True, **result})
except (ValueError, RuntimeError) as exc:
logger.warning(f"Runtime audio-device selection failed: {exc}")
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
logger.exception("Runtime audio-device selection failed")
return web.json_response({"error": str(exc)}, 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_get("/api/audio-devices", handle_get_audio_devices)
app.router.add_post("/api/audio-device", handle_set_audio_device)
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)
app.router.add_post("/api/session/reset", handle_reset_session)
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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoiceAgent Companion</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0f19;
--surface: #131b2e;
--surface-card: #1c273e;
--surface-border: #2a3854;
--primary: #6366f1;
--primary-glow: rgba(99, 102, 241, 0.25);
--secondary: #a855f7;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--user-bg: #1e1b4b;
--user-border: #4338ca;
--assistant-bg: #1e293b;
--assistant-border: #334155;
--tool-bg: #0f172a;
--tool-border: #1e293b;
--accent-green: #10b981;
--accent-orange: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
width: 100%;
height: 100%;
position: relative;
}
.main-chat {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
background: radial-gradient(circle at 50% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 60%);
}
header {
padding: 16px 24px;
background: rgba(19, 27, 46, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
}
.logo-group {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(0.95); opacity: 0.8; }
}
h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.02em; }
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.pill {
background: var(--surface-card);
border: 1px solid var(--surface-border);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.82rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.pill:hover { border-color: var(--primary); color: var(--text-main); }
.pill strong { color: var(--text-main); font-weight: 500; }
.chat-feed {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 18px;
scroll-behavior: smooth;
}
.message-card {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 820px;
width: 100%;
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.message-card.user { align-self: flex-end; }
.message-bubble {
padding: 14px 18px;
border-radius: 16px;
font-size: 0.95rem;
line-height: 1.55;
position: relative;
}
.message-card.user .message-bubble {
background: var(--user-bg);
border: 1px solid var(--user-border);
border-bottom-right-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}
.message-card.assistant .message-bubble {
background: var(--assistant-bg);
border: 1px solid var(--assistant-border);
border-bottom-left-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}
.message-meta {
font-size: 0.75rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
}
.message-card.user .message-meta { justify-content: flex-end; }
/* Grouped Tool Execution Steps inside Assistant Turn Card */
.turn-tools-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.turn-tools-container:empty {
display: none;
margin-bottom: 0;
}
.tool-step {
background: var(--tool-bg);
border: 1px solid var(--tool-border);
border-left: 3px solid var(--primary);
border-radius: 8px;
padding: 8px 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
color: #d1d5db;
}
.tool-step-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: var(--primary);
}
.tool-step-body {
margin-top: 6px;
white-space: pre-wrap;
word-break: break-all;
color: #9ca3af;
background: rgba(0,0,0,0.35);
padding: 8px;
border-radius: 6px;
max-height: 180px;
overflow-y: auto;
}
.turn-text-content {
font-size: 0.95rem;
line-height: 1.55;
}
.turn-text-content:empty {
display: none;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.4);
color: #818cf8;
padding: 2px 8px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.file-link:hover {
background: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
/* Bottom Text Input Bar */
.chat-input-container {
padding: 16px 24px;
background: rgba(19, 27, 46, 0.85);
backdrop-filter: blur(12px);
border-top: 1px solid var(--surface-border);
display: flex;
align-items: center;
gap: 12px;
z-index: 10;
}
.chat-input-container input {
flex: 1;
background: var(--bg);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 12px 18px;
color: white;
font-size: 0.95rem;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.2s;
}
.chat-input-container input:focus {
border-color: var(--primary);
box-shadow: 0 0 12px var(--primary-glow);
}
.send-btn {
background: var(--primary);
color: white;
border: none;
border-radius: 12px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.send-btn:hover { background: #4f46e5; }
.send-btn:active { transform: scale(0.96); }
.file-drawer {
width: 480px;
background: var(--surface);
border-left: 1px solid var(--surface-border);
display: flex;
flex-direction: column;
height: 100%;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
position: absolute;
right: 0;
top: 0;
z-index: 20;
box-shadow: -10px 0 30px rgba(0,0,0,0.5);
}
.file-drawer.open { transform: translateX(0); }
.drawer-header {
padding: 16px 20px;
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.drawer-header h3 {
font-size: 0.95rem;
font-family: 'JetBrains Mono', monospace;
color: var(--text-main);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.4rem;
cursor: pointer;
}
.close-btn:hover { color: var(--text-main); }
.drawer-body {
flex: 1;
padding: 16px;
overflow-y: auto;
background: #090d16;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
line-height: 1.6;
color: #e5e7eb;
white-space: pre-wrap;
}
/* Model Picker Modal */
.modal-overlay {
position: fixed;
top:0; left:0; width:100%; height:100%;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: var(--surface);
border: 1px solid var(--surface-border);
border-radius: 16px;
width: 520px;
max-height: 80vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 { font-size: 1.1rem; font-weight: 600; }
.search-input {
background: var(--bg);
border: 1px solid var(--surface-border);
color: white;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.9rem;
width: 100%;
}
.model-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
max-height: 400px;
padding-right: 4px;
}
.category-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--primary);
font-weight: 700;
margin-bottom: 6px;
}
.model-item {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 6px;
}
.model-item:hover {
border-color: var(--primary);
background: var(--user-bg);
}
.model-item.active {
border-color: var(--accent-green);
background: rgba(16, 185, 129, 0.1);
}
.model-item-name { font-size: 0.9rem; font-weight: 500; }
.model-item-id { font-size: 0.75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.modal-buttons { display: flex; justify-content: flex-end; gap: 8px; }
.btn {
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
}
.btn-secondary { background: var(--surface-card); color: var(--text-muted); }
</style>
</head>
<body>
<div class="app-container">
<div class="main-chat">
<header>
<div class="logo-group">
<div class="status-dot"></div>
<h1>VoiceAgent Companion</h1>
</div>
<div class="controls">
<div class="pill" id="hermesModePill" title="Hermes Mode: API (Daemon) vs CLI (Subprocess)">
Hermes: <strong id="hermesModeText" style="color: #10b981;">API (9119)</strong>
</div>
<div class="pill" id="latencyPill" title="Turn Latency Profiling Metric">
Latency: <strong id="latencyText" style="color: #818cf8;">-- ms</strong>
</div>
<div class="pill" id="modelPill" onclick="openModelPicker()">
Model: <strong id="modelName">Loading...</strong>
</div>
<div class="pill" id="voicePill" onclick="openVoiceModal()">
Voice: <strong id="voiceName">Loading...</strong>
</div>
<div class="pill" id="resetPill" onclick="resetSession()" title="Start a fresh conversation session">
🔄 <strong>Reset Session</strong>
</div>
</div>
</header>
<div class="chat-feed" id="chatFeed">
<div class="message-card assistant">
<div class="message-bubble">
<div class="turn-text-content">
👋 Welcome! VoiceAgent Companion is active. Speech audio, turns, typed commands, and real-time tool executions will appear here live.
</div>
</div>
<div class="message-meta">System • Ready</div>
</div>
</div>
<!-- Bottom Text Input Bar -->
<div class="chat-input-container">
<input type="text" id="userInput" placeholder="Type a message or command (e.g. show bot.py, switch to luna, paseo ls)..." autocomplete="off" onkeydown="handleKeyDown(event)">
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
</button>
</div>
</div>
<div class="file-drawer" id="fileDrawer">
<div class="drawer-header">
<h3 id="drawerFileName">File Viewer</h3>
<button class="close-btn" onclick="closeDrawer()">&times;</button>
</div>
<div class="drawer-body" id="drawerFileContent">Select a file to inspect.</div>
</div>
</div>
<!-- Model Picker Modal -->
<div class="modal-overlay" id="modelModalOverlay">
<div class="modal">
<div class="modal-header">
<h2>Select AI Model</h2>
<button class="close-btn" onclick="closeModelPicker()">&times;</button>
</div>
<input type="text" class="search-input" id="modelSearchInput" placeholder="Filter models (e.g. luna, gpt, deepseek)..." oninput="filterModels()">
<div class="model-list" id="modelListContainer">Loading models...</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeModelPicker()">Cancel</button>
</div>
</div>
</div>
<!-- Voice Modal -->
<div class="modal-overlay" id="voiceModalOverlay">
<div class="modal" style="width:400px;">
<div class="modal-header">
<h2>Set TTS Voice</h2>
<button class="close-btn" onclick="closeVoiceModal()">&times;</button>
</div>
<input type="text" class="search-input" id="voiceInput" placeholder="Enter voice name (e.g. af_heart, am_michael, Moira)...">
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeVoiceModal()">Cancel</button>
<button class="btn" style="background:var(--primary); color:white;" onclick="saveVoice()">Save Voice</button>
</div>
</div>
</div>
<script>
const feed = document.getElementById('chatFeed');
const modelNameEl = document.getElementById('modelName');
const voiceNameEl = document.getElementById('voiceName');
const drawer = document.getElementById('fileDrawer');
const drawerFileName = document.getElementById('drawerFileName');
const drawerFileContent = document.getElementById('drawerFileContent');
const userInputEl = document.getElementById('userInput');
let rawModelsCategories = {};
let activeModelId = '';
// Active Assistant Turn Card references
let currentAssistantCard = null;
let currentToolsContainer = null;
let currentTextContent = null;
function connectSSE() {
const evtSource = new EventSource('/api/stream');
evtSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
handleEvent(data);
} catch(err) {
console.error('Failed to parse SSE payload', err);
}
};
evtSource.onerror = function() {
console.warn('SSE connection error; retrying...');
};
}
function handleEvent(data) {
if (data.type === 'init') {
modelNameEl.textContent = data.model || 'Default';
voiceNameEl.textContent = data.voice || 'af_heart';
activeModelId = data.model;
if (data.history && data.history.length > 0) {
data.history.forEach(ev => renderEvent(ev));
}
} else if (data.type === 'status_change') {
if (data.model) { modelNameEl.textContent = data.model; activeModelId = data.model; }
if (data.voice) voiceNameEl.textContent = data.voice;
} else if (data.type === 'hermes_status') {
const hermesEl = document.getElementById('hermesModeText');
if (hermesEl && data.mode) {
hermesEl.textContent = data.mode;
hermesEl.style.color = data.is_api ? '#10b981' : '#f59e0b';
}
} else if (data.type === 'profiling') {
const latEl = document.getElementById('latencyText');
if (latEl && data.total_ms !== undefined) {
latEl.textContent = `${data.total_ms} ms (${data.mode || ''})`;
latEl.style.color = data.total_ms < 1500 ? '#10b981' : '#818cf8';
}
} else if (data.type === 'show_file') {
if (data.name && data.content) {
drawerFileName.textContent = data.name;
drawerFileContent.textContent = data.content;
drawer.classList.add('open');
} else if (data.path) {
openFile(data.path);
}
} else {
renderEvent(data);
}
}
let currentHasPartialText = false;
function renderEvent(data) {
if (data.type === 'heard' && data.text) {
appendUserMessage(data.text, data.at);
} else if (data.type === 'fast_reply' && data.text) {
appendFastReply(data.text, data.is_complete, data.at);
} else if (data.type === 'thinking' && data.text) {
appendThinkingStep(data.text, data.at);
} else if (data.type === 'partial_reply' && data.text) {
appendPartialReply(data.text, data.at);
} else if (data.type === 'reply' && data.text) {
appendFinalReply(data.text, data.at);
} else if (data.type === 'tool' || data.type === 'tool_event') {
appendToolStep(data.name || 'Tool', data.detail || '', data.at);
}
}
let currentThinkingContent = null;
function appendThinkingStep(text, timestamp) {
ensureAssistantCard(timestamp);
if (!currentThinkingContent) {
const step = document.createElement('div');
step.className = 'thinking-step';
step.style.cssText = 'margin-bottom: 10px; padding: 12px 16px; background: linear-gradient(135deg, rgba(168, 85, 247, 0.12), rgba(126, 34, 206, 0.06)); border: 1px solid rgba(168, 85, 247, 0.35); border-left: 4px solid #a855f7; border-radius: 10px; box-shadow: 0 4px 14px rgba(168, 85, 247, 0.15); font-size: 13.5px; color: #e9d5ff; backdrop-filter: blur(8px);';
step.innerHTML = `
<div style="font-weight: 700; text-transform: uppercase; font-size: 11px; letter-spacing: 0.8px; color: #c084fc; margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
<span>🧠 REASONING PROCESS</span>
</div>
<div class="thinking-text" style="white-space: pre-wrap; font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1.5; color: #f3e8ff;"></div>
`;
currentToolsContainer.appendChild(step);
currentThinkingContent = step.querySelector('.thinking-text');
}
currentThinkingContent.textContent += text;
feed.scrollTop = feed.scrollHeight;
}
function appendUserMessage(text, timestamp) {
// Reset active assistant turn card so the next turn starts fresh
currentAssistantCard = null;
currentToolsContainer = null;
currentTextContent = null;
currentThinkingContent = null;
currentHasPartialText = false;
const card = document.createElement('div');
card.className = 'message-card user';
card.innerHTML = `
<div class="message-bubble">${escapeHtml(text)}</div>
<div class="message-meta">You • ${formatTime(timestamp)}</div>
`;
feed.appendChild(card);
feed.scrollTop = feed.scrollHeight;
}
function ensureAssistantCard(timestamp) {
if (!currentAssistantCard) {
const card = document.createElement('div');
card.className = 'message-card assistant';
card.innerHTML = `
<div class="message-bubble">
<div class="turn-tools-container"></div>
<div class="turn-text-content"></div>
</div>
<div class="message-meta">VoiceAgent • ${formatTime(timestamp)}</div>
`;
feed.appendChild(card);
currentAssistantCard = card;
currentToolsContainer = card.querySelector('.turn-tools-container');
currentTextContent = card.querySelector('.turn-text-content');
}
return currentAssistantCard;
}
function appendToolStep(name, detail, timestamp) {
ensureAssistantCard(timestamp);
const step = document.createElement('div');
step.className = 'tool-step';
step.style.cssText = 'margin-bottom: 10px; padding: 12px 16px; background: linear-gradient(135deg, rgba(6, 182, 212, 0.12), rgba(14, 116, 144, 0.06)); border: 1px solid rgba(6, 182, 212, 0.35); border-left: 4px solid #06b6d4; border-radius: 10px; box-shadow: 0 4px 14px rgba(6, 182, 212, 0.15); backdrop-filter: blur(8px);';
step.innerHTML = `
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #22d3ee; margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
<span>⚡ TOOL EXECUTED: ${escapeHtml(name)}</span>
</div>
${detail ? `<div style="margin-top: 6px; padding: 8px 10px; background: rgba(0,0,0,0.4); border: 1px solid rgba(6, 182, 212, 0.2); border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 12.5px; color: #67e8f9; max-height: 180px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">${escapeHtml(detail)}</div>` : ''}
`;
currentToolsContainer.appendChild(step);
feed.scrollTop = feed.scrollHeight;
}
function appendPartialReply(text, timestamp) {
ensureAssistantCard(timestamp);
currentTextContent.style.display = 'block';
currentTextContent.style.cssText = 'display: block; margin-top: 6px; font-size: 14.5px; line-height: 1.6; color: #f3f4f6; white-space: pre-wrap;';
if (currentTextContent.textContent.length > 0 && !currentTextContent.textContent.endsWith(' ') && !text.startsWith(' ')) {
currentTextContent.appendChild(document.createTextNode(' '));
}
const span = document.createElement('span');
span.innerHTML = linkifyFiles(escapeHtml(text));
span.style.cssText = 'opacity: 0; transition: opacity 0.2s ease-in;';
currentTextContent.appendChild(span);
setTimeout(() => { span.style.opacity = '1'; }, 10);
currentHasPartialText = true;
feed.scrollTop = feed.scrollHeight;
}
function appendFinalReply(text, timestamp) {
ensureAssistantCard(timestamp);
const formatted = linkifyFiles(escapeHtml(text));
currentTextContent.style.display = 'block';
currentTextContent.style.cssText = 'display: block; margin-top: 6px; font-size: 14.5px; line-height: 1.6; color: #f3f4f6; white-space: pre-wrap;';
currentTextContent.innerHTML = formatted;
feed.scrollTop = feed.scrollHeight;
}
function linkifyFiles(text) {
const regex = /([a-zA-Z0-9_\-\/]+\.(py|md|json|txt|sh|swift))/g;
return text.replace(regex, function(match) {
return `<span class="file-link" onclick="openFile('${match}')">📄 ${match}</span>`;
});
}
function openFile(filePath) {
fetch('/api/file?path=' + encodeURIComponent(filePath))
.then(res => res.json())
.then(data => {
if (data.error) {
alert(data.error);
} else {
drawerFileName.textContent = data.name;
drawerFileContent.textContent = data.content;
drawer.classList.add('open');
}
})
.catch(err => alert('Failed to load file: ' + err));
}
function closeDrawer() {
drawer.classList.remove('open');
}
function openModelPicker() {
document.getElementById('modelModalOverlay').style.display = 'flex';
fetch('/api/models')
.then(res => res.json())
.then(data => {
rawModelsCategories = data.categories || {};
activeModelId = data.active || activeModelId;
renderModelList();
});
}
function closeModelPicker() {
document.getElementById('modelModalOverlay').style.display = 'none';
}
function renderModelList() {
const container = document.getElementById('modelListContainer');
const search = document.getElementById('modelSearchInput').value.toLowerCase().trim();
container.innerHTML = '';
for (const [catName, items] of Object.entries(rawModelsCategories)) {
const filtered = items.filter(item =>
item.id.toLowerCase().includes(search) || item.name.toLowerCase().includes(search)
);
if (filtered.length > 0) {
const catTitle = document.createElement('div');
catTitle.className = 'category-title';
catTitle.textContent = catName;
container.appendChild(catTitle);
filtered.forEach(item => {
const isAct = (item.id === activeModelId);
const div = document.createElement('div');
div.className = 'model-item' + (isAct ? ' active' : '');
div.onclick = () => selectModel(item.id);
div.innerHTML = `
<div>
<div class="model-item-name">${escapeHtml(item.name)} ${isAct ? '✓' : ''}</div>
<div class="model-item-id">${escapeHtml(item.id)}</div>
</div>
`;
container.appendChild(div);
});
}
}
}
function filterModels() {
renderModelList();
}
function selectModel(modelId) {
fetch('/api/model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: modelId})
})
.then(r => r.json())
.then(res => {
if (res.error) alert(res.error);
else {
modelNameEl.textContent = res.model;
activeModelId = res.model;
closeModelPicker();
}
});
}
function openVoiceModal() {
document.getElementById('voiceInput').value = voiceNameEl.textContent;
document.getElementById('voiceModalOverlay').style.display = 'flex';
}
function closeVoiceModal() {
document.getElementById('voiceModalOverlay').style.display = 'none';
}
function saveVoice() {
const val = document.getElementById('voiceInput').value.trim();
if (!val) return;
fetch('/api/voice', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice: val})
})
.then(r => r.json())
.then(res => {
if (res.error) alert(res.error);
else {
voiceNameEl.textContent = res.voice;
closeVoiceModal();
}
});
}
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatTime(isoStr) {
if (!isoStr) return new Date().toLocaleTimeString();
try { return new Date(isoStr).toLocaleTimeString(); }
catch(e) { return isoStr; }
}
connectSSE();
</script>
</body>
</html>
"""