Files
VoiceAgent/web_server.py
T

1058 lines
37 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
_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 = """<!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="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>
</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 === '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 === '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);
}
}
function sendMessage() {
const text = userInputEl.value.trim();
if (!text) return;
userInputEl.value = '';
fetch('/api/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text})
})
.then(r => r.json())
.then(res => {
if (res.error) alert(res.error);
})
.catch(err => console.error('Failed to send message:', err));
}
function handleKeyDown(e) {
if (e.key === 'Enter') {
sendMessage();
}
}
function appendUserMessage(text, timestamp) {
// Reset active assistant turn card so the next turn starts fresh
currentAssistantCard = null;
currentToolsContainer = null;
currentTextContent = 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.innerHTML = `
<div class="tool-step-header">⚡ Tool Executed: ${escapeHtml(name)}</div>
${detail ? `<div class="tool-step-body">${escapeHtml(detail)}</div>` : ''}
`;
currentToolsContainer.appendChild(step);
feed.scrollTop = feed.scrollHeight;
}
function appendPartialReply(text, timestamp) {
ensureAssistantCard(timestamp);
const formatted = linkifyFiles(escapeHtml(text));
if (currentTextContent.innerHTML) {
currentTextContent.innerHTML += ' ' + formatted;
} else {
currentTextContent.innerHTML = formatted;
}
currentHasPartialText = true;
feed.scrollTop = feed.scrollHeight;
}
function appendFinalReply(text, timestamp) {
ensureAssistantCard(timestamp);
if (!currentHasPartialText) {
const formatted = linkifyFiles(escapeHtml(text));
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>
"""