| <!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 { |
| |
| --bg-paper: #fbf7ee; |
| --bg-dots: rgba(0, 0, 0, 0.04); |
| --ink: #1e1e24; |
| |
| --comic-yellow: #fef08a; |
| --comic-cyan: #bae6fd; |
| --comic-mint: #bbf7d0; |
| --comic-coral: #fed7aa; |
| --comic-pink: #fecdd3; |
| |
| --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: 20px; |
| } |
| |
| .badge-row { |
| display: flex; |
| gap: 10px; |
| 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.82rem; |
| font-family: 'JetBrains Mono', monospace; |
| font-weight: 700; |
| text-decoration: none; |
| box-shadow: var(--shadow-hard-sm); |
| transition: transform 0.15s ease; |
| display: inline-block; |
| } |
| .badge:hover { |
| transform: translate(-1px, -1px); |
| } |
| |
| .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.1rem; |
| font-weight: 700; |
| color: #4b5563; |
| } |
| |
| |
| .main-grid { |
| display: grid; |
| grid-template-columns: 1fr; |
| gap: 24px; |
| width: 100%; |
| max-width: 1140px; |
| } |
| @media (min-width: 880px) { |
| .main-grid { grid-template-columns: 1.15fr 1fr; } |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .endpoint-details { |
| background: #fefce8; |
| border: 2px solid var(--ink); |
| border-radius: 12px; |
| padding: 10px 14px; |
| margin-bottom: 14px; |
| box-shadow: var(--shadow-hard-sm); |
| font-size: 0.9rem; |
| font-weight: 700; |
| } |
| .endpoint-details summary { |
| cursor: pointer; |
| outline: none; |
| user-select: none; |
| } |
| .endpoint-config-grid { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| margin-top: 10px; |
| padding-top: 8px; |
| border-top: 2px dashed var(--ink); |
| } |
| .input-group label { |
| display: block; |
| font-size: 0.8rem; |
| font-family: 'JetBrains Mono', monospace; |
| margin-bottom: 3px; |
| } |
| .input-group input { |
| width: 100%; |
| padding: 6px 10px; |
| border: 1.5px solid var(--ink); |
| border-radius: 6px; |
| font-size: 0.85rem; |
| font-family: 'JetBrains Mono', monospace; |
| } |
| |
| |
| .tts-controls-box { |
| background: #fefce8; |
| border: 2px solid var(--ink); |
| border-radius: 12px; |
| padding: 12px 14px; |
| margin-bottom: 16px; |
| box-shadow: var(--shadow-hard-sm); |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| .tts-row { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| flex-wrap: wrap; |
| gap: 8px; |
| font-size: 0.95rem; |
| font-weight: 700; |
| } |
| .tts-row 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 10px; |
| border-radius: 8px; |
| outline: none; |
| cursor: pointer; |
| box-shadow: 2px 2px 0px var(--ink); |
| } |
| .model-loader-bar { |
| height: 8px; |
| background: #e2e8f0; |
| border: 1.5px solid var(--ink); |
| border-radius: 9999px; |
| overflow: hidden; |
| display: none; |
| } |
| .model-loader-fill { |
| height: 100%; |
| background: #10b981; |
| width: 0%; |
| transition: width 0.2s ease; |
| } |
| .tts-status-label { |
| font-size: 0.8rem; |
| font-family: 'JetBrains Mono', monospace; |
| color: #065f46; |
| font-weight: 700; |
| display: none; |
| } |
| |
| |
| .orb-container { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| padding: 10px 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); |
| animation: comicPulse 1s infinite alternate ease-in-out; |
| } |
| .voice-orb.speaking { |
| background: var(--comic-coral); |
| transform: scale(1.08); |
| 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); |
| } |
| |
| |
| .chips-row { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| margin: 10px 0 12px; |
| } |
| .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); |
| } |
| |
| |
| .chat-box { |
| flex: 1; |
| min-height: 230px; |
| max-height: 310px; |
| overflow-y: auto; |
| display: flex; |
| flex-direction: column; |
| gap: 14px; |
| padding: 10px 8px 10px 2px; |
| margin: 6px 0 14px; |
| } |
| .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-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; |
| } |
| |
| |
| .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" id="device-cap-badge">⚡ Device Check: WASM / WebGPU</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">Comic interface with lightweight on-device neural TTS (Kokoro-82M) & live XKCD tools.</p> |
| </header> |
|
|
| <main class="main-grid"> |
| |
| <div class="panel"> |
| |
| |
| <details class="endpoint-details"> |
| <summary>⚙️ <strong>PhoneLLM Model Endpoint Settings (vLLM / Modal / SGLang / HF)</strong></summary> |
| <div class="endpoint-config-grid"> |
| <div class="input-group"> |
| <label>API Base URL (OpenAI-compatible):</label> |
| <input id="api-base-url" type="text" placeholder="e.g. https://your-modal-app.modal.run/v1 or http://localhost:8000/v1" /> |
| </div> |
| <div class="input-group"> |
| <label>API Key (Optional):</label> |
| <input id="api-key" type="password" placeholder="Bearer Token / API Key" /> |
| </div> |
| <div class="input-group"> |
| <label>Model ID:</label> |
| <input id="model-id" type="text" value="pipecat-ai/phonellm-alpha-1" /> |
| </div> |
| </div> |
| </details> |
|
|
| <div class="tts-controls-box"> |
| <div class="tts-row"> |
| <span>🧠 <strong>TTS Engine:</strong></span> |
| <select id="tts-engine-select"> |
| <option value="kokoro">Kokoro-82M Neural Model (On-Device)</option> |
| <option value="system">Native System Speech (Instant)</option> |
| </select> |
| </div> |
| <div class="tts-row" id="voice-row"> |
| <span>🎭 <strong>Voice Profile:</strong></span> |
| <select id="voice-select"> |
| <option value="af_heart">Heart (Female US - Warm)</option> |
| <option value="af_bella">Bella (Female US - Energetic)</option> |
| <option value="am_michael">Michael (Male US - Narrator)</option> |
| <option value="bf_emma">Emma (Female UK)</option> |
| <option value="bm_george">George (Male UK)</option> |
| </select> |
| </div> |
| <div class="model-loader-bar" id="loader-bar"> |
| <div class="model-loader-fill" id="loader-fill"></div> |
| </div> |
| <span class="tts-status-label" id="loader-label">⚡ Loading Kokoro 82M Neural Weights...</span> |
| </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> |
|
|
| |
| <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 type="module"> |
| import { KokoroTTS } from "https://cdn.jsdelivr.net/npm/kokoro-js@1.2.1"; |
| |
| 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 ttsEngineSelect = document.getElementById('tts-engine-select'); |
| const voiceSelect = document.getElementById('voice-select'); |
| const loaderBar = document.getElementById('loader-bar'); |
| const loaderFill = document.getElementById('loader-fill'); |
| const loaderLabel = document.getElementById('loader-label'); |
| const deviceCapBadge = document.getElementById('device-cap-badge'); |
| |
| let isListening = false; |
| let recognition = null; |
| let kokoroModel = null; |
| let isLoadingKokoro = false; |
| let currentAudio = null; |
| |
| |
| async function checkDeviceCapability() { |
| const hasWebGPU = !!navigator.gpu; |
| const deviceLabel = hasWebGPU ? "🚀 Hardware: WebGPU Accelerated" : "⚡ Hardware: WASM SIMD Mode"; |
| deviceCapBadge.textContent = deviceLabel; |
| console.log("Device Capability:", deviceLabel); |
| } |
| checkDeviceCapability(); |
| |
| |
| async function initKokoroModel() { |
| if (kokoroModel || isLoadingKokoro) return; |
| isLoadingKokoro = true; |
| loaderBar.style.display = "block"; |
| loaderLabel.style.display = "block"; |
| loaderLabel.textContent = "⚡ Initializing Kokoro-82M ONNX in-browser..."; |
| |
| try { |
| const device = navigator.gpu ? "webgpu" : "wasm"; |
| kokoroModel = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { |
| dtype: "q8", |
| device: device, |
| progress_callback: (progress) => { |
| if (progress.status === "progress" && progress.total) { |
| const pct = Math.round((progress.loaded / progress.total) * 100); |
| loaderFill.style.width = pct + "%"; |
| loaderLabel.textContent = `⚡ Loading Kokoro weights: ${pct}% (${(progress.loaded / 1024 / 1024).toFixed(1)}MB)`; |
| } |
| } |
| }); |
| |
| loaderFill.style.width = "100%"; |
| loaderLabel.textContent = "✅ Kokoro-82M Neural Model Ready On-Device!"; |
| setTimeout(() => { |
| loaderBar.style.display = "none"; |
| loaderLabel.style.display = "none"; |
| }, 2500); |
| } catch (err) { |
| console.warn("Kokoro on-device load error, fallback to system TTS:", err); |
| loaderLabel.textContent = "⚠️ On-device model fallback to System TTS"; |
| ttsEngineSelect.value = "system"; |
| } finally { |
| isLoadingKokoro = false; |
| } |
| } |
| |
| |
| initKokoroModel(); |
| |
| |
| async function speak(text) { |
| if (currentAudio) { |
| currentAudio.pause(); |
| currentAudio = null; |
| } |
| if ('speechSynthesis' in window) { |
| window.speechSynthesis.cancel(); |
| } |
| |
| orb.classList.remove('listening'); |
| orb.classList.add('speaking'); |
| statusBadge.textContent = "🔊 Speaking response..."; |
| |
| const engine = ttsEngineSelect.value; |
| const chosenVoice = voiceSelect.value || "af_heart"; |
| |
| |
| if (engine === "kokoro" && kokoroModel) { |
| try { |
| statusBadge.textContent = "🧠 Neural synthesizing on-device..."; |
| const audioOutput = await kokoroModel.generate(text, { |
| voice: chosenVoice, |
| speed: 1.05 |
| }); |
| |
| const blob = audioOutput.toBlob(); |
| const audioUrl = URL.createObjectURL(blob); |
| currentAudio = new Audio(audioUrl); |
| |
| currentAudio.onended = () => { |
| orb.classList.remove('speaking'); |
| statusBadge.textContent = "Ready!"; |
| }; |
| currentAudio.onerror = () => { |
| orb.classList.remove('speaking'); |
| statusBadge.textContent = "Ready!"; |
| }; |
| |
| await currentAudio.play(); |
| return; |
| } catch (e) { |
| console.warn("Kokoro synthesis error, falling back to System Speech:", e); |
| } |
| } |
| |
| |
| if ('speechSynthesis' in window) { |
| const utterance = new SpeechSynthesisUtterance(text); |
| const voices = window.speechSynthesis.getVoices(); |
| const preferredVoice = voices.find(v => v.lang.startsWith('en') && (v.name.includes('Natural') || v.name.includes('Samantha') || v.name.includes('Google') || v.name.includes('Siri'))); |
| if (preferredVoice) utterance.voice = preferredVoice; |
| utterance.rate = 1.05; |
| |
| utterance.onend = () => { |
| orb.classList.remove('speaking'); |
| statusBadge.textContent = "Ready!"; |
| }; |
| utterance.onerror = () => { |
| orb.classList.remove('speaking'); |
| statusBadge.textContent = "Ready!"; |
| }; |
| window.speechSynthesis.speak(utterance); |
| } |
| } |
| |
| |
| 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 |
| }; |
| |
| |
| 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 }; |
| } |
| |
| |
| const apiBaseInput = document.getElementById('api-base-url'); |
| const apiKeyInput = document.getElementById('api-key'); |
| const modelIdInput = document.getElementById('model-id'); |
| |
| |
| const PHONELLM_TOOLS_SCHEMA = [ |
| { |
| type: "function", |
| function: { |
| name: "get_latest_xkcd", |
| description: "Fetch the latest published XKCD comic with its title, image URL, and alt-text punchline.", |
| parameters: { type: "object", properties: {}, required: [] } |
| } |
| }, |
| { |
| type: "function", |
| function: { |
| name: "get_random_xkcd", |
| description: "Fetch a random XKCD comic strip from the entire archive.", |
| parameters: { type: "object", properties: {}, required: [] } |
| } |
| }, |
| { |
| type: "function", |
| function: { |
| name: "search_xkcd_by_topic", |
| description: "Search or retrieve an XKCD comic about a specific topic (e.g. 'python', 'git', 'sql', 'ai', 'tar', 'regex', 'compiling').", |
| parameters: { |
| type: "object", |
| properties: { |
| topic: { type: "string", description: "The topic or programming concept to search for." } |
| }, |
| required: ["topic"] |
| } |
| } |
| }, |
| { |
| type: "function", |
| function: { |
| name: "get_xkcd_by_number", |
| description: "Fetch a specific XKCD comic by its exact comic number.", |
| parameters: { |
| type: "object", |
| properties: { |
| comic_num: { type: "integer", description: "The exact comic number to retrieve." } |
| }, |
| required: ["comic_num"] |
| } |
| } |
| } |
| ]; |
| |
| let conversationHistory = [ |
| { |
| role: "system", |
| content: "You are XKCD Voice Bot, powered by pipecat-ai/phonellm-alpha-1. You have live tool access to https://xkcd.hemanth.deno.net. Always use your tools to look up comics. Keep responses punchy and mention the title, comic number, and punchline alt-text." |
| } |
| ]; |
| |
| |
| async function callLivePhoneLLMEndpoint(userText) { |
| const baseUrl = (apiBaseInput.value || "").trim().replace(/\/+$/, ""); |
| if (!baseUrl) return null; |
| |
| const apiKey = (apiKeyInput.value || "").trim(); |
| const model = (modelIdInput.value || "pipecat-ai/phonellm-alpha-1").trim(); |
| |
| conversationHistory.push({ role: "user", content: userText }); |
| |
| const headers = { "Content-Type": "application/json" }; |
| if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; |
| |
| try { |
| statusBadge.textContent = "⚡ Calling live PhoneLLM model..."; |
| |
| const response = await fetch(`${baseUrl}/chat/completions`, { |
| method: "POST", |
| headers: headers, |
| body: JSON.stringify({ |
| model: model, |
| messages: conversationHistory, |
| tools: PHONELLM_TOOLS_SCHEMA, |
| tool_choice: "auto", |
| temperature: 0.0 |
| }) |
| }); |
| |
| if (!response.ok) { |
| throw new Error(`PhoneLLM endpoint returned HTTP ${response.status}`); |
| } |
| |
| const data = await response.json(); |
| const choice = data.choices[0]; |
| const message = choice.message; |
| let comic = null; |
| let toolName = null; |
| |
| if (message.tool_calls && message.tool_calls.length > 0) { |
| conversationHistory.push(message); |
| |
| for (const tc of message.tool_calls) { |
| const fnName = tc.function.name; |
| const fnArgs = JSON.parse(tc.function.arguments || "{}"); |
| toolName = `${fnName}(${JSON.stringify(fnArgs)})`; |
| statusBadge.textContent = `⚙️ PhoneLLM invoked tool: ${fnName}...`; |
| |
| let toolResult = null; |
| if (fnName === "get_latest_xkcd") { |
| toolResult = await toolGetLatestXkcd(); |
| comic = toolResult; |
| } else if (fnName === "get_random_xkcd") { |
| toolResult = await toolGetRandomXkcd(); |
| comic = toolResult; |
| } else if (fnName === "get_xkcd_by_number") { |
| toolResult = await toolGetXkcdByNumber(fnArgs.comic_num || 1); |
| comic = toolResult; |
| } else if (fnName === "search_xkcd_by_topic") { |
| const res = await toolSearchXkcdByTopic(fnArgs.topic || "python"); |
| toolResult = res.comic; |
| comic = toolResult; |
| } |
| |
| conversationHistory.push({ |
| role: "tool", |
| tool_call_id: tc.id, |
| content: JSON.stringify(toolResult) |
| }); |
| } |
| |
| |
| const finalRes = await fetch(`${baseUrl}/chat/completions`, { |
| method: "POST", |
| headers: headers, |
| body: JSON.stringify({ |
| model: model, |
| messages: conversationHistory, |
| temperature: 0.0 |
| }) |
| }); |
| |
| const finalData = await finalRes.json(); |
| const finalReply = finalData.choices[0].message.content; |
| conversationHistory.push({ role: "assistant", content: finalReply }); |
| |
| return { replyText: finalReply, comic: comic, toolName: toolName }; |
| } else { |
| conversationHistory.push({ role: "assistant", content: message.content }); |
| return { replyText: message.content, comic: null, toolName: null }; |
| } |
| } catch (err) { |
| console.warn("Live PhoneLLM API failed, falling back to local engine:", err); |
| return null; |
| } |
| } |
| |
| |
| async function processPhoneLLMTurn(userText) { |
| statusBadge.textContent = "⚡ PhoneLLM Reasoning..."; |
| addMessage(userText, "user"); |
| |
| |
| const liveResult = await callLivePhoneLLMEndpoint(userText); |
| if (liveResult) { |
| addMessage(liveResult.replyText, "agent", liveResult.toolName); |
| if (liveResult.comic) renderComic(liveResult.comic); |
| speak(liveResult.replyText); |
| return; |
| } |
| |
| const lower = userText.toLowerCase(); |
| let comic = null; |
| let toolName = null; |
| let replyText = ""; |
| |
| const numMatch = lower.match(/\b(?:comic|number|#)?\s*(\d+)\b/); |
| |
| |
| 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()); |
| }); |
| |
| |
| 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 { |
| if (currentAudio) currentAudio.pause(); |
| if ('speechSynthesis' in window) window.speechSynthesis.cancel(); |
| orb.classList.remove('speaking'); |
| recognition.start(); |
| } |
| }); |
| |
| |
| setTimeout(() => { |
| addMessage("🗯️ Hey! I am your XKCD voice bot powered by PhoneLLM Alpha 1 with on-device Kokoro TTS. Ask me for comics on Python, Git, SQL, or say 'Random'!", "agent"); |
| }, 400); |
| </script> |
| </body> |
| </html> |
|
|