gnumanth's picture
feat: live PhoneLLM (pipecat-ai/phonellm-alpha-1) endpoint integration with full tool calling protocol
ad9fd32
Raw
History Blame Contribute Delete
35.6 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-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;
}
/* Comic Layout Grid */
.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; }
}
/* 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;
}
.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 Banner */
.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;
}
/* Comic Mic Orb */
.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);
}
/* Comic Prompt Chips */
.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);
}
/* Speech Bubble Chat */
.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 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" 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">
<!-- Left Voice & Speech Bubble Panel -->
<div class="panel">
<!-- TTS Engine & Voice Config -->
<!-- PhoneLLM Model Endpoint Configuration -->
<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>
<!-- 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 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;
// Detect Device Hardware Acceleration Capability
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();
// Initialize Kokoro-82M Lightweight On-Device Neural Model
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", // 8-bit quantized: fast, small footprint, cached in browser!
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;
}
}
// Pre-warm on-device model if device is capable
initKokoroModel();
// High-Fidelity Audio Synthesizer
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";
// 1. Try On-Device Kokoro-82M Neural TTS if loaded or loading
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);
}
}
// 2. System Speech Fallback
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);
}
}
// 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
};
// 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 };
}
const apiBaseInput = document.getElementById('api-base-url');
const apiKeyInput = document.getElementById('api-key');
const modelIdInput = document.getElementById('model-id');
// PhoneLLM Tool Definition Schema (OpenAI Compatible)
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."
}
];
// Call live PhoneLLM vLLM / Modal endpoint if configured
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...";
// Call PhoneLLM with temperature=0 & thinking disabled (per PhoneLLM spec)
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)
});
}
// Request final voice reply from PhoneLLM
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;
}
}
// PhoneLLM Conversational & Tool Calling Engine
async function processPhoneLLMTurn(userText) {
statusBadge.textContent = "⚡ PhoneLLM Reasoning...";
addMessage(userText, "user");
// Try Live PhoneLLM Endpoint first if configured by 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/);
// Check for topic queries first
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 {
if (currentAudio) currentAudio.pause();
if ('speechSynthesis' in window) 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 with on-device Kokoro TTS. Ask me for comics on Python, Git, SQL, or say 'Random'!", "agent");
}, 400);
</script>
</body>
</html>