- LiteLLM proxy with langfuse callbacks, postgres backends, and OpenRouter fallbacks. - Langfuse observability UI. - Pipecat voice pipeline (LiveKit + STT + TTS + LLM) and Silero TTS build contexts. - Ollama tuned for GPU (OLLAMA_NUM_GPU=999, mem_limit=4g, max 2 loaded models). - open-webui wired to litellm + faster-whisper + silero for voice. - litellm-config.yaml publishes oO's model aliases (tip-generator, embedder, judge) pointing at the host ollama on :11434 so ml/serving can call them via LiteLLM. .env skipped (secrets). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
242 lines
7.5 KiB
HTML
242 lines
7.5 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Adolf Voice</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: system-ui, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #e0e0e0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
}
|
|
.card {
|
|
background: #1a1a1a;
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 16px;
|
|
padding: 40px;
|
|
text-align: center;
|
|
width: 360px;
|
|
}
|
|
h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 8px; }
|
|
.subtitle { color: #666; font-size: 0.85rem; margin-bottom: 32px; }
|
|
#orb {
|
|
width: 100px;
|
|
height: 100px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, #3a3a3a 0%, #1a1a1a 100%);
|
|
border: 2px solid #333;
|
|
margin: 0 auto 24px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 2rem;
|
|
user-select: none;
|
|
}
|
|
#orb.listening {
|
|
background: radial-gradient(circle, #1e3a5f 0%, #0d1f33 100%);
|
|
border-color: #3b82f6;
|
|
box-shadow: 0 0 20px #3b82f640;
|
|
animation: pulse-blue 1.5s ease-in-out infinite;
|
|
}
|
|
#orb.speaking {
|
|
background: radial-gradient(circle, #1e4034 0%, #0d2018 100%);
|
|
border-color: #22c55e;
|
|
box-shadow: 0 0 20px #22c55e40;
|
|
animation: pulse-green 0.8s ease-in-out infinite;
|
|
}
|
|
#orb.thinking {
|
|
background: radial-gradient(circle, #3a2e1e 0%, #1a160d 100%);
|
|
border-color: #f59e0b;
|
|
box-shadow: 0 0 20px #f59e0b40;
|
|
animation: pulse-amber 1s ease-in-out infinite;
|
|
}
|
|
#orb.user-speaking {
|
|
background: radial-gradient(circle, #3a1e3a 0%, #1a0d1a 100%);
|
|
border-color: #a855f7;
|
|
box-shadow: 0 0 20px #a855f740;
|
|
animation: pulse-purple 0.6s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse-blue { 0%,100%{box-shadow:0 0 20px #3b82f640} 50%{box-shadow:0 0 35px #3b82f680} }
|
|
@keyframes pulse-green { 0%,100%{box-shadow:0 0 20px #22c55e40} 50%{box-shadow:0 0 35px #22c55e80} }
|
|
@keyframes pulse-amber { 0%,100%{box-shadow:0 0 20px #f59e0b40} 50%{box-shadow:0 0 35px #f59e0b80} }
|
|
@keyframes pulse-purple { 0%,100%{box-shadow:0 0 20px #a855f740} 50%{box-shadow:0 0 35px #a855f780} }
|
|
#status {
|
|
font-size: 0.9rem;
|
|
color: #888;
|
|
margin-bottom: 16px;
|
|
min-height: 1.2em;
|
|
}
|
|
#transcript {
|
|
font-size: 0.8rem;
|
|
color: #555;
|
|
margin-bottom: 20px;
|
|
min-height: 2.4em;
|
|
line-height: 1.4;
|
|
font-style: italic;
|
|
word-break: break-word;
|
|
}
|
|
#transcript .user-text { color: #7ba8d4; font-style: normal; }
|
|
#transcript .bot-text { color: #6ab88a; font-style: normal; }
|
|
#btn {
|
|
background: #2a2a2a;
|
|
border: 1px solid #3a3a3a;
|
|
color: #e0e0e0;
|
|
padding: 10px 28px;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
#btn:hover { background: #333; }
|
|
#btn:disabled { opacity: 0.4; cursor: default; }
|
|
#btn.active { border-color: #ef4444; color: #ef4444; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Adolf</h1>
|
|
<p class="subtitle">Voice assistant</p>
|
|
<div id="orb" onclick="toggle()">🎙️</div>
|
|
<div id="status">Press to connect</div>
|
|
<div id="transcript"></div>
|
|
<button id="btn" onclick="toggle()">Connect</button>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
|
<script>
|
|
let room = null;
|
|
let audioCtx = null;
|
|
|
|
// Unlock browser autoplay — must happen on first user gesture
|
|
function unlockAudio() {
|
|
if (!audioCtx) {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
}
|
|
}
|
|
|
|
function setUI(state, msg) {
|
|
const orb = document.getElementById('orb');
|
|
const status = document.getElementById('status');
|
|
const btn = document.getElementById('btn');
|
|
orb.className = state || '';
|
|
status.textContent = msg;
|
|
if (state === null) {
|
|
btn.textContent = 'Connect';
|
|
btn.classList.remove('active');
|
|
orb.textContent = '🎙️';
|
|
} else {
|
|
btn.textContent = 'Disconnect';
|
|
btn.classList.add('active');
|
|
orb.textContent = state === 'thinking' ? '💭' :
|
|
state === 'speaking' ? '🔊' :
|
|
state === 'user-speaking' ? '🗣️' : '🎙️';
|
|
}
|
|
}
|
|
|
|
function addTranscript(role, text) {
|
|
const div = document.getElementById('transcript');
|
|
const cls = role === 'user' ? 'user-text' : 'bot-text';
|
|
const prefix = role === 'user' ? 'You: ' : 'Adolf: ';
|
|
div.innerHTML = `<span class="${cls}">${prefix}${text}</span>`;
|
|
}
|
|
|
|
async function toggle() {
|
|
unlockAudio();
|
|
if (room) {
|
|
room.disconnect();
|
|
return;
|
|
}
|
|
|
|
document.getElementById('btn').disabled = true;
|
|
setUI('thinking', 'Connecting…');
|
|
|
|
try {
|
|
const res = await fetch('/connect', { method: 'POST' });
|
|
const { token, url } = await res.json();
|
|
|
|
room = new LivekitClient.Room({ adaptiveStream: true, dynacast: true });
|
|
|
|
room.on(LivekitClient.RoomEvent.Connected, () => {
|
|
setUI('listening', 'Listening…');
|
|
document.getElementById('btn').disabled = false;
|
|
});
|
|
|
|
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
|
setUI(null, 'Press to connect');
|
|
document.getElementById('btn').disabled = false;
|
|
document.getElementById('transcript').innerHTML = '';
|
|
room = null;
|
|
});
|
|
|
|
room.on(LivekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
if (!room) return;
|
|
const botSpeaking = speakers.some(s => s.identity === 'pipecat-bot');
|
|
const userSpeaking = speakers.some(s => s.identity === 'user');
|
|
if (botSpeaking) {
|
|
setUI('speaking', 'Adolf is speaking…');
|
|
} else if (userSpeaking) {
|
|
setUI('user-speaking', 'Listening to you…');
|
|
} else {
|
|
setUI('listening', 'Listening…');
|
|
}
|
|
});
|
|
|
|
// Attach remote audio so browser plays it
|
|
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
|
if (track.kind === 'audio') {
|
|
// Remove old element if any
|
|
const old = document.getElementById(`audio-${participant.identity}`);
|
|
if (old) old.remove();
|
|
const el = track.attach();
|
|
el.id = `audio-${participant.identity}`;
|
|
el.autoplay = true;
|
|
// Resume audio context on attach to beat autoplay restrictions
|
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
|
document.body.appendChild(el);
|
|
el.play().catch(() => {});
|
|
}
|
|
});
|
|
|
|
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track) => {
|
|
track.detach().forEach(el => el.remove());
|
|
});
|
|
|
|
room.on(LivekitClient.RoomEvent.ParticipantConnected, (p) => {
|
|
if (p.identity === 'pipecat-bot') {
|
|
setUI('listening', 'Listening…');
|
|
}
|
|
});
|
|
|
|
// Data messages from bot (transcripts/events if pipecat sends them)
|
|
room.on(LivekitClient.RoomEvent.DataReceived, (data, participant) => {
|
|
try {
|
|
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
if (msg.type === 'transcript' && msg.role === 'user') addTranscript('user', msg.text);
|
|
if (msg.type === 'transcript' && msg.role === 'bot') addTranscript('bot', msg.text);
|
|
} catch {}
|
|
});
|
|
|
|
const wsUrl = url.replace(/^http/, 'ws');
|
|
await room.connect(wsUrl, token);
|
|
await room.localParticipant.setMicrophoneEnabled(true);
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
setUI(null, 'Error: ' + err.message);
|
|
document.getElementById('btn').disabled = false;
|
|
room = null;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|