feat: integrate JV Voice Profile cloning, Voicebox research, and ensure session voice/markdown prompt nudge
This commit is contained in:
+298
-47
@@ -67,6 +67,64 @@ 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,
|
||||
@@ -283,6 +341,10 @@ async def start_server(workspace: Path, host: str = "127.0.0.1", port: int = 888
|
||||
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)
|
||||
@@ -313,9 +375,18 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
<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;
|
||||
@@ -363,26 +434,32 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
background: rgba(19, 27, 46, 0.85);
|
||||
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;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
}
|
||||
.logo-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@@ -390,59 +467,83 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
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);
|
||||
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; }
|
||||
.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: 24px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 820px;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
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;
|
||||
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 {
|
||||
@@ -512,11 +613,55 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
|
||||
.turn-text-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
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;
|
||||
@@ -724,13 +869,23 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
<div class="app-container">
|
||||
<div class="main-chat">
|
||||
<header>
|
||||
<div class="logo-group">
|
||||
<div class="status-dot"></div>
|
||||
<h1>VoiceAgent Companion</h1>
|
||||
<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 (9119)</strong>
|
||||
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>
|
||||
@@ -741,9 +896,6 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
<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>
|
||||
|
||||
@@ -760,7 +912,7 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
|
||||
<!-- 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)">
|
||||
<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>
|
||||
@@ -799,14 +951,44 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
<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">
|
||||
<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="saveVoice()">Save Voice</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');
|
||||
@@ -823,6 +1005,11 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
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');
|
||||
|
||||
@@ -842,14 +1029,19 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
|
||||
function handleEvent(data) {
|
||||
if (data.type === 'init') {
|
||||
modelNameEl.textContent = data.model || 'Default';
|
||||
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 = data.model; activeModelId = data.model; }
|
||||
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');
|
||||
@@ -986,11 +1178,24 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
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 = linkifyFiles(escapeHtml(text));
|
||||
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; white-space: pre-wrap;';
|
||||
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;
|
||||
}
|
||||
@@ -1099,7 +1304,49 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
document.getElementById('voiceModalOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveVoice() {
|
||||
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', {
|
||||
@@ -1117,6 +1364,10 @@ HTML_INDEX = """<!DOCTYPE html>
|
||||
});
|
||||
}
|
||||
|
||||
function saveVoice() {
|
||||
applyVoice();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user