1386 lines
52 KiB
Python
1386 lines
52 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_manifest(request):
|
|
manifest = {
|
|
"name": "VoiceAgent Companion",
|
|
"short_name": "VoiceAgent",
|
|
"description": "Minimal High-Performance AI Voice Companion",
|
|
"start_url": "/",
|
|
"display": "standalone",
|
|
"background_color": "#0b0f19",
|
|
"theme_color": "#0b0f19",
|
|
"orientation": "any",
|
|
"icons": [
|
|
{
|
|
"src": "/icon.svg",
|
|
"sizes": "any",
|
|
"type": "image/svg+xml",
|
|
"purpose": "any maskable"
|
|
}
|
|
]
|
|
}
|
|
return web.json_response(manifest)
|
|
|
|
|
|
async def handle_service_worker(request):
|
|
sw_code = """
|
|
const CACHE_NAME = 'voiceagent-pwa-v1';
|
|
self.addEventListener('install', (e) => self.skipWaiting());
|
|
self.addEventListener('activate', (e) => e.waitUntil(clients.claim()));
|
|
self.addEventListener('fetch', (e) => {
|
|
if (e.request.url.includes('/api/')) return;
|
|
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
|
|
});
|
|
"""
|
|
return web.Response(text=sw_code, content_type="application/javascript")
|
|
|
|
|
|
async def handle_icon_svg(request):
|
|
svg_icon = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
<defs>
|
|
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stop-color="#0b0f19"/>
|
|
<stop offset="100%" stop-color="#1e1b4b"/>
|
|
</linearGradient>
|
|
<linearGradient id="glowGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stop-color="#6366f1"/>
|
|
<stop offset="100%" stop-color="#a855f7"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="512" height="512" rx="120" fill="url(#bgGrad)"/>
|
|
<circle cx="256" cy="256" r="170" fill="none" stroke="url(#glowGrad)" stroke-width="14" opacity="0.3"/>
|
|
<g transform="translate(106, 106)">
|
|
<rect x="120" y="60" width="60" height="150" rx="30" fill="url(#glowGrad)"/>
|
|
<path d="M 60 180 A 90 90 0 0 0 240 180" fill="none" stroke="url(#glowGrad)" stroke-width="20" stroke-linecap="round"/>
|
|
<line x1="150" y1="270" x2="150" y2="320" stroke="url(#glowGrad)" stroke-width="20" stroke-linecap="round"/>
|
|
</g>
|
|
</svg>"""
|
|
return web.Response(text=svg_icon, content_type="image/svg+xml")
|
|
|
|
|
|
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("/manifest.json", handle_manifest)
|
|
app.router.add_get("/sw.js", handle_service_worker)
|
|
app.router.add_get("/icon.svg", handle_icon_svg)
|
|
app.router.add_get("/apple-touch-icon.png", handle_icon_svg)
|
|
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="manifest" href="/manifest.json">
|
|
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
<link rel="apple-touch-icon" href="/icon.svg">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="VoiceAgent">
|
|
<meta name="theme-color" content="#0b0f19">
|
|
<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">
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<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: 10px 14px;
|
|
background: rgba(19, 27, 46, 0.92);
|
|
backdrop-filter: blur(12px);
|
|
border-bottom: 1px solid var(--surface-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
z-index: 10;
|
|
}
|
|
.header-top {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
}
|
|
.logo-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background-color: var(--accent-green);
|
|
box-shadow: 0 0 8px 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: 0.95rem; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
|
|
.quick-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
overflow-x: auto;
|
|
width: 100%;
|
|
scrollbar-width: none;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding-bottom: 2px;
|
|
}
|
|
.controls::-webkit-scrollbar { display: none; }
|
|
.pill {
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--surface-border);
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.76rem;
|
|
color: var(--text-muted);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.pill:hover { border-color: var(--primary); color: var(--text-main); }
|
|
.pill strong {
|
|
color: var(--text-main);
|
|
font-weight: 500;
|
|
max-width: 110px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
display: inline-block;
|
|
vertical-align: bottom;
|
|
}
|
|
|
|
.chat-feed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.message-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
max-width: 100%;
|
|
width: 100%;
|
|
animation: fadeIn 0.25s ease-out forwards;
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.message-card.user { align-self: flex-end; }
|
|
|
|
.message-bubble {
|
|
padding: 10px 14px;
|
|
border-radius: 14px;
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
position: relative;
|
|
word-break: break-word;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.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.6;
|
|
color: #f3f4f6;
|
|
}
|
|
.turn-text-content:empty {
|
|
display: none;
|
|
}
|
|
.turn-text-content p {
|
|
margin-bottom: 8px;
|
|
}
|
|
.turn-text-content p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.turn-text-content a {
|
|
color: #818cf8;
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
font-weight: 500;
|
|
transition: color 0.15s;
|
|
}
|
|
.turn-text-content a:hover {
|
|
color: #a5b4fc;
|
|
}
|
|
.turn-text-content code {
|
|
background: rgba(0, 0, 0, 0.4);
|
|
border: 1px solid var(--surface-border);
|
|
padding: 2px 6px;
|
|
border-radius: 6px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.88em;
|
|
color: #e0e7ff;
|
|
}
|
|
.turn-text-content pre {
|
|
background: #090d16;
|
|
border: 1px solid var(--surface-border);
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
overflow-x: auto;
|
|
margin: 10px 0;
|
|
}
|
|
.turn-text-content pre code {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
.turn-text-content ul, .turn-text-content ol {
|
|
margin-left: 20px;
|
|
margin-top: 6px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.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="header-top">
|
|
<div class="logo-group">
|
|
<div class="status-dot"></div>
|
|
<h1>VoiceAgent</h1>
|
|
</div>
|
|
<div class="quick-actions">
|
|
<div class="pill" id="resetPill" onclick="resetSession()" title="Start a fresh conversation session">
|
|
🔄 <strong>Reset</strong>
|
|
</div>
|
|
<div class="pill" id="pwaInstallBtn" onclick="installPWA()" title="Install VoiceAgent Companion WebApp" style="display:none; background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(168, 85, 247, 0.25)); border-color: rgba(168, 85, 247, 0.5);">
|
|
📲 <strong style="color: #c084fc;">Install</strong>
|
|
</div>
|
|
</div>
|
|
</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</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>
|
|
</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="Ask or type a command (e.g. show bot.py)..." 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()">×</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()">×</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()">×</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" style="margin-top:16px;">
|
|
<button class="btn btn-secondary" onclick="closeVoiceModal()">Cancel</button>
|
|
<button class="btn" style="background:var(--primary); color:white;" onclick="applyVoice()">Apply Voice</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// PWA Service Worker Registration & Installation logic
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(reg => console.log('PWA ServiceWorker registered:', reg))
|
|
.catch(err => console.debug('ServiceWorker error:', err));
|
|
});
|
|
}
|
|
|
|
let deferredPrompt = null;
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
const installBtn = document.getElementById('pwaInstallBtn');
|
|
if (installBtn) installBtn.style.display = 'inline-flex';
|
|
});
|
|
|
|
function installPWA() {
|
|
if (!deferredPrompt) return;
|
|
deferredPrompt.prompt();
|
|
deferredPrompt.userChoice.then((choiceResult) => {
|
|
if (choiceResult.outcome === 'accepted') {
|
|
console.log('User accepted PWA installation');
|
|
}
|
|
deferredPrompt = null;
|
|
const installBtn = document.getElementById('pwaInstallBtn');
|
|
if (installBtn) installBtn.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
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 formatModelName(model) {
|
|
if (!model) return 'Default';
|
|
return model.includes('/') ? model.split('/').pop() : model;
|
|
}
|
|
|
|
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 = formatModelName(data.model);
|
|
modelNameEl.title = 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 = formatModelName(data.model);
|
|
modelNameEl.title = 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 renderMarkdown(rawText) {
|
|
if (!rawText) return '';
|
|
if (typeof marked !== 'undefined' && marked.parse) {
|
|
try {
|
|
let parsed = marked.parse(rawText, { gfm: true, breaks: true });
|
|
return linkifyFiles(parsed);
|
|
} catch (e) {
|
|
console.warn("Marked parse error:", e);
|
|
}
|
|
}
|
|
return linkifyFiles(escapeHtml(rawText));
|
|
}
|
|
|
|
function appendFinalReply(text, timestamp) {
|
|
ensureAssistantCard(timestamp);
|
|
const formatted = renderMarkdown(text);
|
|
currentTextContent.style.display = 'block';
|
|
currentTextContent.style.cssText = 'display: block; margin-top: 6px; font-size: 14.5px; line-height: 1.6; color: #f3f4f6;';
|
|
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 handleKeyDown(event) {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
sendMessage();
|
|
}
|
|
}
|
|
|
|
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(res => res.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
alert('Error sending message: ' + data.error);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to send message:', err);
|
|
});
|
|
}
|
|
|
|
function resetSession() {
|
|
fetch('/api/session/reset', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
if (res.error) alert(res.error);
|
|
else {
|
|
feed.innerHTML = '';
|
|
ensureAssistantCard();
|
|
currentTextContent.style.display = 'block';
|
|
currentTextContent.textContent = '🔄 Hermes session reset. Ready for a new conversation!';
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyVoice() {
|
|
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 saveVoice() {
|
|
applyVoice();
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function formatTime(isoStr) {
|
|
if (!isoStr) return new Date().toLocaleTimeString();
|
|
try { return new Date(isoStr).toLocaleTimeString(); }
|
|
catch(e) { return isoStr; }
|
|
}
|
|
|
|
connectSSE();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|