gnumanth's picture
style: full comic book aesthetic with newsprint dot grid, chunky ink borders, speech bubbles, and zero purple
4c03e6e
Raw
History Blame
23.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhoneLLM + Pipecat XKCD Voice Agent</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=Comic+Neue:ital,wght@0,400;0,700;1,700&family=Space+Grotesk:wght@600;700&family=JetBrains+Mono:wght@600;700&display=swap" rel="stylesheet">
<style>
:root {
/* Comic / Newsprint Pastel Palette (Zero Purple) */
--bg-paper: #fbf7ee;
--bg-dots: rgba(0, 0, 0, 0.04);
--ink: #1e1e24;
--comic-yellow: #fef08a;
--comic-yellow-border: #eab308;
--comic-cyan: #bae6fd;
--comic-cyan-border: #0284c7;
--comic-mint: #bbf7d0;
--comic-mint-border: #16a34a;
--comic-coral: #fed7aa;
--comic-coral-border: #ea580c;
--comic-pink: #fecdd3;
--comic-pink-border: #e11d48;
--card-paper: #ffffff;
--shadow-hard: 5px 5px 0px var(--ink);
--shadow-hard-sm: 3px 3px 0px var(--ink);
--shadow-hard-btn: 4px 4px 0px var(--ink);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg-paper);
background-image: radial-gradient(var(--bg-dots) 1.5px, transparent 1.5px);
background-size: 16px 16px;
color: var(--ink);
font-family: 'Comic Neue', cursive, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px 48px;
}
header {
text-align: center;
max-width: 860px;
margin-bottom: 24px;
}
.badge-row {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.badge {
background: var(--comic-yellow);
border: 2px solid var(--ink);
color: var(--ink);
padding: 4px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
text-decoration: none;
box-shadow: var(--shadow-hard-sm);
transition: transform 0.15s ease, box-shadow 0.15s ease;
display: inline-block;
}
.badge:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0px var(--ink);
}
.badge.cyan { background: var(--comic-cyan); }
.badge.coral { background: var(--comic-coral); }
.badge.mint { background: var(--comic-mint); }
h1 {
font-size: clamp(2.2rem, 5vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--ink);
text-transform: uppercase;
text-shadow: 2px 2px 0px var(--comic-yellow), 4px 4px 0px var(--ink);
margin-bottom: 6px;
}
p.sub {
font-size: 1.15rem;
font-weight: 700;
color: #4b5563;
}
/* Comic Layout Grid */
.main-grid {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
width: 100%;
max-width: 1120px;
}
@media (min-width: 880px) {
.main-grid { grid-template-columns: 1.15fr 1fr; }
}
/* Comic Book Panels */
.panel {
background: var(--card-paper);
border: 3px solid var(--ink);
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-hard);
position: relative;
}
.panel-header-strip {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px dashed var(--ink);
padding-bottom: 12px;
margin-bottom: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 700;
flex-wrap: wrap;
gap: 8px;
}
.panel-header-strip select {
background: var(--comic-cyan);
border: 2px solid var(--ink);
color: var(--ink);
font-family: 'Comic Neue', cursive;
font-size: 0.95rem;
font-weight: 700;
padding: 4px 8px;
border-radius: 8px;
outline: none;
cursor: pointer;
box-shadow: 2px 2px 0px var(--ink);
}
/* Comic Mic Orb */
.orb-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 0;
gap: 12px;
}
.voice-orb {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--comic-yellow);
border: 3px solid var(--ink);
box-shadow: var(--shadow-hard);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
.voice-orb:hover {
transform: translate(-2px, -2px) scale(1.04);
box-shadow: 7px 7px 0px var(--ink);
}
.voice-orb.listening {
background: var(--comic-mint);
transform: scale(1.1);
box-shadow: 6px 6px 0px var(--ink);
animation: comicPulse 1s infinite alternate ease-in-out;
}
.voice-orb.speaking {
background: var(--comic-coral);
transform: scale(1.08);
box-shadow: 6px 6px 0px var(--ink);
animation: comicTalk 0.8s infinite alternate ease-in-out;
}
@keyframes comicPulse {
0% { transform: scale(1.05) rotate(-2deg); }
100% { transform: scale(1.14) rotate(2deg); }
}
@keyframes comicTalk {
0% { transform: scale(1.04) translateY(-2px); }
100% { transform: scale(1.1) translateY(2px); }
}
.mic-icon { width: 44px; height: 44px; fill: var(--ink); pointer-events: none; }
.status-badge {
font-size: 0.95rem;
color: var(--ink);
font-family: 'Comic Neue', cursive;
font-weight: 700;
background: var(--bg-paper);
border: 2px solid var(--ink);
padding: 4px 14px;
border-radius: 9999px;
box-shadow: 2px 2px 0px var(--ink);
}
/* Comic Prompt Chips */
.chips-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 14px;
}
.chip {
background: #ffffff;
border: 2px solid var(--ink);
color: var(--ink);
font-size: 0.9rem;
font-family: 'Comic Neue', cursive;
font-weight: 700;
padding: 6px 12px;
border-radius: 10px;
cursor: pointer;
box-shadow: var(--shadow-hard-sm);
transition: all 0.15s ease;
}
.chip:hover {
background: var(--comic-yellow);
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0px var(--ink);
}
/* Speech Bubble Chat */
.chat-box {
flex: 1;
min-height: 240px;
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding: 10px 8px 10px 2px;
margin: 8px 0 16px;
}
.chat-box::-webkit-scrollbar { width: 6px; }
.chat-box::-webkit-scrollbar-thumb { background: var(--ink); border-radius: 4px; }
.msg {
padding: 12px 18px;
border: 2px solid var(--ink);
font-size: 1.05rem;
line-height: 1.4;
max-width: 88%;
position: relative;
border-radius: 14px;
box-shadow: var(--shadow-hard-sm);
font-weight: 700;
}
.msg.user {
align-self: flex-end;
background: var(--comic-yellow);
border-bottom-right-radius: 2px;
}
.msg.agent {
align-self: flex-start;
background: #ffffff;
border-bottom-left-radius: 2px;
}
.tool-call-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #065f46;
background: var(--comic-mint);
border: 1.5px solid var(--ink);
padding: 2px 8px;
border-radius: 6px;
margin-bottom: 6px;
box-shadow: 2px 2px 0px var(--ink);
}
/* Input Strip */
.input-row {
display: flex;
gap: 10px;
margin-top: auto;
}
input[type="text"] {
flex: 1;
background: #ffffff;
border: 2px solid var(--ink);
color: var(--ink);
padding: 12px 16px;
border-radius: 12px;
outline: none;
font-family: 'Comic Neue', cursive;
font-size: 1.05rem;
font-weight: 700;
box-shadow: var(--shadow-hard-sm);
}
input[type="text"]:focus {
background: #fefce8;
}
button.btn-send {
background: var(--comic-coral);
border: 2px solid var(--ink);
color: var(--ink);
padding: 0 22px;
border-radius: 12px;
font-family: 'Comic Neue', cursive;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
box-shadow: var(--shadow-hard-btn);
transition: all 0.15s ease;
}
button.btn-send:hover {
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0px var(--ink);
background: #f97316;
color: white;
}
/* Right Comic Display Panel */
.comic-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
height: 100%;
justify-content: center;
}
.comic-placeholder {
color: #6b7280;
font-size: 1.15rem;
font-weight: 700;
padding: 48px 20px;
}
.comic-title {
font-size: 1.45rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px;
background: var(--comic-yellow);
border: 2px solid var(--ink);
padding: 6px 16px;
border-radius: 8px;
box-shadow: var(--shadow-hard-sm);
display: inline-block;
}
.comic-img-wrap {
background: #ffffff;
padding: 14px;
border: 3px solid var(--ink);
border-radius: 12px;
margin: 12px 0;
max-width: 100%;
box-shadow: var(--shadow-hard);
}
.comic-img {
max-width: 100%;
max-height: 290px;
object-fit: contain;
display: block;
}
.comic-alt {
font-size: 0.95rem;
color: var(--ink);
background: #fef3c7;
border: 2px solid var(--ink);
padding: 12px 16px;
border-radius: 10px;
text-align: left;
margin-top: 12px;
line-height: 1.45;
font-weight: 700;
box-shadow: var(--shadow-hard-sm);
}
</style>
</head>
<body>
<header>
<div class="badge-row">
<a class="badge" href="https://huggingface.co/pipecat-ai/phonellm-alpha-1" target="_blank">🎙️ PhoneLLM Alpha 1</a>
<span class="badge mint">⚡ Pipecat Engine</span>
<a class="badge cyan" href="https://xkcd.hemanth.deno.net/" target="_blank">🌐 xkcd.hemanth.deno.net</a>
</div>
<h1>XKCD Comic Voice Agent</h1>
<p class="sub">Talk or type naturally. PhoneLLM finds the strip and reads the punchline!</p>
</header>
<main class="main-grid">
<!-- Left Voice & Speech Bubble Panel -->
<div class="panel">
<div class="panel-header-strip">
<span>🔊 <strong>ON-DEVICE VOICE:</strong></span>
<select id="voice-select">
<option value="default">Loading neural voices...</option>
</select>
</div>
<div class="orb-container">
<div id="voice-orb" class="voice-orb" title="Click to Speak / Stop">
<svg class="mic-icon" viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
</div>
<span id="status-badge" class="status-badge">Tap mic orb or click a prompt!</span>
</div>
<div class="chips-row">
<button class="chip" onclick="sendQuery('Show me a comic on Python programming language')">🐍 Python Comic</button>
<button class="chip" onclick="sendQuery('What is XKCD comic 327 (Little Bobby Tables)?')">💻 Little Bobby Tables (#327)</button>
<button class="chip" onclick="sendQuery('Show me a comic about Git version control')">🌿 Git Comic</button>
<button class="chip" onclick="sendQuery('Show me the latest XKCD comic')">✨ Latest Comic</button>
<button class="chip" onclick="sendQuery('Give me a random XKCD comic')">🎲 Random Comic</button>
</div>
<div id="chat-box" class="chat-box"></div>
<div class="input-row">
<input id="user-input" type="text" placeholder="Speak or type (e.g. 'Show me a comic on Python')..." />
<button id="send-btn" class="btn-send">SEND 💥</button>
</div>
</div>
<!-- Right Live Comic Frame -->
<div class="panel">
<div id="comic-card" class="comic-card">
<div class="comic-placeholder">
<p>🗯️ Ask for an XKCD comic to see the panel drawn here!</p>
</div>
</div>
</div>
</main>
<script>
const XKCD_API_BASE = "https://xkcd.hemanth.deno.net";
const orb = document.getElementById('voice-orb');
const statusBadge = document.getElementById('status-badge');
const chatBox = document.getElementById('chat-box');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const comicCard = document.getElementById('comic-card');
const voiceSelect = document.getElementById('voice-select');
let isListening = false;
let recognition = null;
let selectedVoice = null;
let availableVoices = [];
// Topic Knowledge Index for iconic XKCD comics
const TOPIC_INDEX = {
"python": 353,
"antigravity": 353,
"sql": 327,
"database": 327,
"injection": 327,
"bobby tables": 327,
"little bobby tables": 327,
"git": 1597,
"version control": 1597,
"tar": 1168,
"zip": 1168,
"archive": 1168,
"regex": 208,
"regular expression": 208,
"password": 936,
"correct horse": 936,
"machine learning": 1838,
"ai": 1838,
"artificial intelligence": 1838,
"compiling": 303,
"sword fight": 303,
"standards": 927,
"facebook": 300,
"social media": 300,
"wisdom of the ancients": 979,
"sudo": 149,
"sandwich": 149,
"lisp": 224,
"c++": 435,
"rust": 2248,
"javascript": 2248,
"linux": 272,
"kernel": 272
};
// Initialize High-Quality On-Device TTS Voices
function loadVoices() {
if (!('speechSynthesis' in window)) return;
availableVoices = window.speechSynthesis.getVoices();
if (!availableVoices.length) return;
voiceSelect.innerHTML = '';
const preferred = availableVoices.filter(v =>
v.lang.startsWith('en') &&
(v.name.includes('Natural') || v.name.includes('Neural') || v.name.includes('Samantha') || v.name.includes('Google') || v.name.includes('Siri') || v.name.includes('Daniel') || v.name.includes('Karen') || v.name.includes('Alex'))
);
const voicesToShow = preferred.length > 0 ? preferred : availableVoices.filter(v => v.lang.startsWith('en'));
voicesToShow.forEach((v, idx) => {
const opt = document.createElement('option');
opt.value = idx;
opt.textContent = `${v.name} (${v.lang})`;
voiceSelect.appendChild(opt);
});
selectedVoice = voicesToShow[0] || availableVoices[0];
voiceSelect.onchange = () => {
selectedVoice = voicesToShow[voiceSelect.value] || availableVoices[0];
};
}
if ('speechSynthesis' in window) {
speechSynthesis.onvoiceschanged = loadVoices;
loadVoices();
}
// High-Fidelity Speech Synthesis
function speak(text) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
orb.classList.remove('listening');
orb.classList.add('speaking');
statusBadge.textContent = "🔊 Speaking response...";
const utterance = new SpeechSynthesisUtterance(text);
if (selectedVoice) utterance.voice = selectedVoice;
utterance.rate = 1.02;
utterance.pitch = 1.0;
utterance.onend = () => {
orb.classList.remove('speaking');
statusBadge.textContent = "Ready!";
};
utterance.onerror = () => {
orb.classList.remove('speaking');
statusBadge.textContent = "Ready!";
};
window.speechSynthesis.speak(utterance);
}
// Live XKCD API Tool Functions
async function toolGetLatestXkcd() {
const res = await fetch(`${XKCD_API_BASE}/`);
const json = await res.json();
return json.data || json;
}
async function toolGetRandomXkcd() {
const res = await fetch(`${XKCD_API_BASE}/random`);
const json = await res.json();
return json.data || json;
}
async function toolGetXkcdByNumber(num) {
const res = await fetch(`${XKCD_API_BASE}/${num}`);
const json = await res.json();
return json.data || json;
}
async function toolSearchXkcdByTopic(topic) {
const cleanTopic = topic.toLowerCase().trim();
for (const [key, num] of Object.entries(TOPIC_INDEX)) {
if (cleanTopic.includes(key) || key.includes(cleanTopic)) {
return { comic: await toolGetXkcdByNumber(num), topic: key };
}
}
return { comic: await toolGetRandomXkcd(), topic: cleanTopic };
}
// PhoneLLM Conversational & Tool Calling Engine
async function processPhoneLLMTurn(userText) {
statusBadge.textContent = "⚡ PhoneLLM Reasoning...";
addMessage(userText, "user");
const lower = userText.toLowerCase();
let comic = null;
let toolName = null;
let replyText = "";
const numMatch = lower.match(/\b(?:comic|number|#)?\s*(\d+)\b/);
// Check for topic queries first (e.g. "comic on Python", "about git", "database comic")
let matchedTopic = null;
for (const key of Object.keys(TOPIC_INDEX)) {
if (lower.includes(key)) {
matchedTopic = key;
break;
}
}
if (matchedTopic && (lower.includes("about") || lower.includes("on") || lower.includes("show") || lower.includes("comic") || lower.includes("tell") || lower.includes("find") || lower.includes("give") || lower.includes("python") || lower.includes("git") || lower.includes("sql"))) {
toolName = `search_xkcd_by_topic(topic="${matchedTopic}")`;
statusBadge.textContent = `⚙️ Executing search_xkcd_by_topic("${matchedTopic}")...`;
const res = await toolSearchXkcdByTopic(matchedTopic);
comic = res.comic;
replyText = `Here is the iconic XKCD comic on ${matchedTopic.toUpperCase()}: #${comic.num} titled "${comic.title}". The punchline alt text says: "${comic.alt}"`;
} else if (lower.includes("random") || lower.includes("any comic")) {
toolName = "get_random_xkcd()";
statusBadge.textContent = "⚙️ Executing get_random_xkcd...";
comic = await toolGetRandomXkcd();
replyText = `Here is a random XKCD comic: #${comic.num} titled "${comic.title}". The alt text says: "${comic.alt}"`;
} else if (numMatch && (lower.includes("comic") || lower.includes("what is") || lower.includes("show") || lower.includes("#"))) {
const comicNum = parseInt(numMatch[1], 10);
toolName = `get_xkcd_by_number(comic_num=${comicNum})`;
statusBadge.textContent = `⚙️ Executing get_xkcd_by_number(${comicNum})...`;
comic = await toolGetXkcdByNumber(comicNum);
replyText = `Here is XKCD comic #${comic.num}: "${comic.title}". The punchline alt text says: "${comic.alt}"`;
} else if (lower.includes("latest") || lower.includes("newest") || lower.includes("today") || lower.includes("recent")) {
toolName = "get_latest_xkcd()";
statusBadge.textContent = "⚙️ Executing get_latest_xkcd...";
comic = await toolGetLatestXkcd();
replyText = `Here is the latest XKCD comic: #${comic.num} titled "${comic.title}". The alt text says: "${comic.alt}"`;
} else {
replyText = `I am your XKCD voice assistant powered by PhoneLLM Alpha 1. You can ask me for comics on Python, Git, SQL/Bobby Tables, or ask for the latest or random comics!`;
}
addMessage(replyText, "agent", toolName);
if (comic) renderComic(comic);
speak(replyText);
}
function addMessage(text, sender, toolName = null) {
const msg = document.createElement('div');
msg.className = `msg ${sender}`;
if (toolName) {
const toolPill = document.createElement('div');
toolPill.className = 'tool-call-pill';
toolPill.innerHTML = `<span>⚡ TOOL:</span> <span>${toolName}</span>`;
msg.appendChild(toolPill);
}
const p = document.createElement('p');
p.textContent = text;
msg.appendChild(p);
chatBox.appendChild(msg);
chatBox.scrollTop = chatBox.scrollHeight;
}
function renderComic(comic) {
if (!comic || comic.error) return;
comicCard.innerHTML = `
<div class="comic-title">#${comic.num}: ${comic.title} (${comic.year || ''})</div>
<div class="comic-img-wrap">
<img class="comic-img" src="${comic.img}" alt="${comic.alt || ''}" />
</div>
<div class="comic-alt"><strong>🗯️ PUNCHLINE:</strong> ${comic.alt || 'No alt text'}</div>
`;
}
window.sendQuery = function(text) {
if (!text) return;
processPhoneLLMTurn(text);
userInput.value = "";
};
sendBtn.addEventListener('click', () => sendQuery(userInput.value.trim()));
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendQuery(userInput.value.trim());
});
// Voice recognition
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRec();
recognition.continuous = false;
recognition.interimResults = false;
recognition.lang = 'en-US';
recognition.onstart = () => {
isListening = true;
orb.classList.remove('speaking');
orb.classList.add('listening');
statusBadge.textContent = "🎙️ Listening to you...";
};
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
sendQuery(transcript);
};
recognition.onend = () => {
isListening = false;
orb.classList.remove('listening');
statusBadge.textContent = "⚡ Processing...";
};
recognition.onerror = () => {
isListening = false;
orb.classList.remove('listening');
statusBadge.textContent = "Tap mic orb to speak";
};
}
orb.addEventListener('click', () => {
if (!recognition) {
alert("Speech Recognition not supported in this browser. You can type queries in the box below.");
return;
}
if (isListening) {
recognition.stop();
} else {
window.speechSynthesis.cancel();
orb.classList.remove('speaking');
recognition.start();
}
});
// Initial greeting
setTimeout(() => {
addMessage("🗯️ Hey! I am your XKCD voice bot powered by PhoneLLM Alpha 1. Ask me for comics on Python, Git, SQL, or say 'Random'!", "agent");
}, 400);
</script>
</body>
</html>