diff --git a/README.md b/README.md index de1bf3f..f6e6f8c 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,26 @@ npm start This launches `http-server` on `http://localhost:8080` so you can verify styling and functionality before publishing. +### GitHub Pages voice bridge (serverless) + +When the site is deployed to GitHub Pages you can use the bundled serverless functions instead of hosting the `server/` application yourself. + +1. Enable **Pages Functions** for the repository (Settings → Pages → Build and deployment → Functions). +2. Add the following environment variables in the same settings screen so the functions can authenticate with Twilio: + - `TWILIO_ACCOUNT_SID` + - `TWILIO_AUTH_TOKEN` + - `TWILIO_PHONE_NUMBER` + - Optional: `POLLINATIONS_VOICE` for a default Pollinations preset, `ALLOWED_ORIGIN` to lock down CORS, and `POLLINATIONS_TOKEN` if you use a private Pollinations referrer/token. +3. Redeploy the site. GitHub Pages will publish the functions at `/_functions/*`. +4. Open the Unity chat settings and leave the **Voice bridge URL** blank. The UI will automatically call the serverless voice bridge. + +The traditional Node server is still available for self-hosting or when you deploy Unity Chat somewhere other than GitHub Pages. + ### Starting a phone call from the UI Open the **Settings** modal and scroll to the **Unity Phone Call** card. Provide: -1. **Voice bridge URL** – The HTTPS base URL where you deployed the server found in `server/`. It must expose the `/api/start-call` endpoint. +1. **Voice bridge URL** – The HTTPS base URL where you deployed the server found in `server/`, or leave the field blank to use the built-in GitHub Pages voice bridge. 2. **Phone number** – Destination number in E.164 format (e.g. `+15551234567`). 3. **Initial topic** (optional) – Unity will open the call with this context. 4. **Pollinations voice** – Voice preset the Twilio call should use (`nova`, `alloy`, `fable`, `onyx`, `shimmer`, or `echo`). diff --git a/functions/_shared/voiceBridge.js b/functions/_shared/voiceBridge.js new file mode 100644 index 0000000..0543332 --- /dev/null +++ b/functions/_shared/voiceBridge.js @@ -0,0 +1,221 @@ +const SYSTEM_PROMPT = + "You are Unity Voice, an AI assistant speaking with a caller over the phone. " + + "Keep every reply under 200 characters, speak naturally, and ask follow-up questions to keep the chat going."; + +const DEFAULT_GATHER_PROMPT = + "After the message, speak your reply and stay on the line for the assistant to respond."; + +const MAX_HISTORY_PAIRS = 6; // 6 user/assistant turns keeps URLs manageable + +function sanitizeForTts(text) { + if (!text) return ""; + const compact = text.replace(/\s+/g, " ").trim(); + if (compact.length <= 380) return compact; + return `${compact.slice(0, 377)}...`; +} + +function createTtsUrl(text, voice = "nova") { + const sanitized = sanitizeForTts(text); + const encoded = encodeURIComponent(sanitized); + const url = new URL(`https://text.pollinations.ai/${encoded}`); + url.searchParams.set("model", "openai-audio"); + url.searchParams.set("voice", voice); + return url.toString(); +} + +function escapeXml(str = "") { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function buildFunctionsBaseUrl(requestUrl) { + const parsed = new URL(requestUrl); + const idx = parsed.pathname.indexOf("/_functions"); + if (idx === -1) { + return parsed.origin; + } + return `${parsed.origin}${parsed.pathname.slice(0, idx + "/_functions".length)}`; +} + +function trimMessages(messages) { + if (!Array.isArray(messages)) return []; + const systemMessages = messages.filter(msg => msg?.role === "system"); + const nonSystem = messages.filter(msg => msg?.role !== "system"); + const trimmed = nonSystem.slice(-MAX_HISTORY_PAIRS * 2); // user + assistant pairs + if (systemMessages.length > 0) { + return [systemMessages[0], ...trimmed]; + } + return trimmed; +} + +function encodeState(state) { + const safeState = { ...state, messages: trimMessages(state.messages) }; + const json = JSON.stringify(safeState); + return encodeURIComponent(btoa(json)); +} + +function decodeState(param) { + if (!param) return null; + try { + const json = atob(decodeURIComponent(param)); + const parsed = JSON.parse(json); + if (!Array.isArray(parsed.messages)) parsed.messages = []; + return parsed; + } catch (error) { + console.error("Failed to decode session state", error); + return null; + } +} + +function buildTwimlResponse(state, encodedState, functionsBase, gatherPrompt = DEFAULT_GATHER_PROMPT) { + const audioUrl = createTtsUrl(state.lastAssistant, state.voice); + const gatherUrl = `${functionsBase}/gather?state=${encodedState}`; + const prompt = escapeXml(gatherPrompt || DEFAULT_GATHER_PROMPT); + + return `\n\n ${audioUrl}\n \n ${prompt}\n \n \n No response detected. Ending the call.\n \n`; +} + +function buildErrorTwiml(message) { + const safe = escapeXml(message || "The session is no longer active. Goodbye."); + return `\n\n ${safe}\n \n`; +} + +function buildCorsHeaders(request, allowedOrigin) { + const requestOrigin = request.headers.get("Origin"); + const headers = new Headers(); + headers.set("Access-Control-Allow-Methods", "POST, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Content-Type"); + headers.set("Vary", "Origin"); + + if (!allowedOrigin || allowedOrigin === "*") { + headers.set("Access-Control-Allow-Origin", "*"); + return headers; + } + + if (allowedOrigin === requestOrigin) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + } else { + headers.set("Access-Control-Allow-Origin", allowedOrigin); + } + return headers; +} + +async function fetchPollinationsResponse(env, state, userMessage) { + if (!state.messages) state.messages = []; + if (!state.voice) state.voice = env?.POLLINATIONS_VOICE || "nova"; + if (!state.gatherPrompt) state.gatherPrompt = DEFAULT_GATHER_PROMPT; + + const trimmed = typeof userMessage === "string" ? userMessage.trim() : ""; + if (trimmed) { + state.messages.push({ role: "user", content: trimmed }); + } + + const payload = { + model: "openai", + messages: trimMessages(state.messages.length ? state.messages : [{ role: "system", content: SYSTEM_PROMPT }]), + temperature: 0.8, + max_output_tokens: 300, + top_p: 0.95, + presence_penalty: 0, + frequency_penalty: 0, + stream: false + }; + + const headers = new Headers({ "Content-Type": "application/json" }); + const token = env?.POLLINATIONS_TOKEN || env?.pollinationsToken; + if (token) headers.set("Authorization", `Bearer ${token}`); + + const response = await fetch("https://text.pollinations.ai/openai", { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Pollinations API error: ${response.status} ${text}`); + } + + const data = await response.json(); + const assistantMessage = data?.choices?.[0]?.message?.content?.trim(); + if (!assistantMessage) { + throw new Error("Pollinations API returned an empty response."); + } + + state.messages.push({ role: "assistant", content: assistantMessage }); + state.messages = trimMessages(state.messages); + state.lastAssistant = assistantMessage; + return assistantMessage; +} + +async function createInitialState(env, voice, initialPrompt) { + const state = { + id: crypto.randomUUID(), + voice: voice || env?.POLLINATIONS_VOICE || "nova", + gatherPrompt: DEFAULT_GATHER_PROMPT, + messages: [{ role: "system", content: SYSTEM_PROMPT }], + lastAssistant: "" + }; + + const seedPrompt = initialPrompt && initialPrompt.trim() + ? initialPrompt.trim() + : "Greet the caller briefly and ask how you can help."; + + await fetchPollinationsResponse(env, state, seedPrompt); + return state; +} + +async function startTwilioCall(env, phoneNumber, voiceResponseUrl) { + const accountSid = env?.TWILIO_ACCOUNT_SID; + const authToken = env?.TWILIO_AUTH_TOKEN; + const fromNumber = env?.TWILIO_PHONE_NUMBER; + + if (!accountSid || !authToken || !fromNumber) { + throw new Error("Twilio credentials are not fully configured."); + } + + const auth = btoa(`${accountSid}:${authToken}`); + const body = new URLSearchParams({ + To: phoneNumber, + From: fromNumber, + Url: voiceResponseUrl, + Method: "POST" + }); + + const endpoint = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`; + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Twilio API error: ${response.status} ${text}`); + } + + return response.json(); +} + +export { + SYSTEM_PROMPT, + DEFAULT_GATHER_PROMPT, + buildCorsHeaders, + buildErrorTwiml, + buildFunctionsBaseUrl, + buildTwimlResponse, + createInitialState, + createTtsUrl, + decodeState, + encodeState, + fetchPollinationsResponse, + startTwilioCall, + trimMessages +}; diff --git a/functions/api/start-call.js b/functions/api/start-call.js new file mode 100644 index 0000000..fe09f0f --- /dev/null +++ b/functions/api/start-call.js @@ -0,0 +1,88 @@ +import { + buildCorsHeaders, + buildFunctionsBaseUrl, + createInitialState, + encodeState, + startTwilioCall +} from "../_shared/voiceBridge.js"; + +function jsonResponse(status, data, headers = new Headers()) { + const merged = new Headers(headers); + if (!merged.has("Content-Type")) { + merged.set("Content-Type", "application/json"); + } + merged.set("Cache-Control", "no-store"); + return new Response(JSON.stringify(data), { status, headers: merged }); +} + +function validateEnvironment(env) { + const missing = []; + if (!env?.TWILIO_ACCOUNT_SID) missing.push("TWILIO_ACCOUNT_SID"); + if (!env?.TWILIO_AUTH_TOKEN) missing.push("TWILIO_AUTH_TOKEN"); + if (!env?.TWILIO_PHONE_NUMBER) missing.push("TWILIO_PHONE_NUMBER"); + if (missing.length) { + throw new Error(`Missing environment variables: ${missing.join(", ")}`); + } +} + +export async function onRequestOptions(context) { + const { request, env } = context; + const headers = buildCorsHeaders(request, env?.ALLOWED_ORIGIN); + headers.set("Content-Length", "0"); + return new Response(null, { status: 204, headers }); +} + +export async function onRequestPost(context) { + const { request, env } = context; + const corsHeaders = buildCorsHeaders(request, env?.ALLOWED_ORIGIN); + + let payload = null; + try { + payload = await request.json(); + } catch (error) { + return jsonResponse(400, { error: "Invalid JSON payload." }, corsHeaders); + } + + const phoneNumber = (payload?.phoneNumber || "").trim(); + const initialPrompt = typeof payload?.initialPrompt === "string" ? payload.initialPrompt : ""; + const voice = (payload?.voice || env?.POLLINATIONS_VOICE || "nova").trim(); + + if (!phoneNumber) { + return jsonResponse(400, { error: "A destination phoneNumber is required." }, corsHeaders); + } + if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { + return jsonResponse(400, { error: "Phone number must be in E.164 format (e.g. +15551234567)." }, corsHeaders); + } + + try { + validateEnvironment(env); + } catch (error) { + return jsonResponse(500, { error: error.message }, corsHeaders); + } + + const functionsBase = buildFunctionsBaseUrl(request.url); + + try { + const state = await createInitialState(env, voice, initialPrompt); + const encodedState = encodeState(state); + const voiceResponseUrl = `${functionsBase}/voice-response?state=${encodedState}`; + + await startTwilioCall(env, phoneNumber, voiceResponseUrl); + + return jsonResponse( + 200, + { + status: "initiated", + message: "Call started. Answer the phone to begin the voice chat.", + sessionToken: encodedState, + gatherPrompt: state.gatherPrompt, + voice: state.voice, + usingPagesBridge: true + }, + corsHeaders + ); + } catch (error) { + console.error("Failed to start call", error); + return jsonResponse(500, { error: error.message || "Failed to start call." }, corsHeaders); + } +} diff --git a/functions/gather.js b/functions/gather.js new file mode 100644 index 0000000..414b52d --- /dev/null +++ b/functions/gather.js @@ -0,0 +1,64 @@ +import { + buildErrorTwiml, + buildFunctionsBaseUrl, + buildTwimlResponse, + decodeState, + encodeState, + fetchPollinationsResponse +} from "./_shared/voiceBridge.js"; + +async function handleGather(context) { + const { request, env } = context; + const url = new URL(request.url); + const stateParam = url.searchParams.get("state"); + + if (!stateParam) { + return new Response(buildErrorTwiml("Session information was not provided."), { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } + + const state = decodeState(stateParam); + if (!state) { + return new Response(buildErrorTwiml("The voice session could not be restored."), { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } + + const formData = await request.formData(); + const speechResult = formData.get("SpeechResult"); + const confidenceRaw = formData.get("Confidence"); + const confidenceValue = confidenceRaw === null ? NaN : Number(confidenceRaw); + const lowConfidence = Number.isFinite(confidenceValue) ? confidenceValue < 0.1 : false; + + const functionsBase = buildFunctionsBaseUrl(request.url); + + if (!speechResult || lowConfidence) { + const encodedState = encodeState(state); + const twiml = buildTwimlResponse(state, encodedState, functionsBase, "I didn't catch that. Please respond after the message."); + return new Response(twiml, { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } + + try { + await fetchPollinationsResponse(env, state, speechResult); + const encodedState = encodeState(state); + const twiml = buildTwimlResponse(state, encodedState, functionsBase, "Share your next reply when you are ready."); + return new Response(twiml, { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } catch (error) { + console.error("Error generating Pollinations response", error); + return new Response(buildErrorTwiml("Something went wrong while generating a response. Ending the call."), { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } +} + +export const onRequestPost = handleGather; diff --git a/functions/voice-response.js b/functions/voice-response.js new file mode 100644 index 0000000..d204ecf --- /dev/null +++ b/functions/voice-response.js @@ -0,0 +1,40 @@ +import { + buildErrorTwiml, + buildFunctionsBaseUrl, + buildTwimlResponse, + decodeState, + encodeState +} from "./_shared/voiceBridge.js"; + +async function handleVoiceResponse(context) { + const { request } = context; + const url = new URL(request.url); + const stateParam = url.searchParams.get("state"); + + if (!stateParam) { + return new Response(buildErrorTwiml("Session information was not provided."), { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } + + const state = decodeState(stateParam); + if (!state || !state.lastAssistant) { + return new Response(buildErrorTwiml("The voice session is no longer active."), { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); + } + + const functionsBase = buildFunctionsBaseUrl(request.url); + const encodedState = encodeState(state); + const twiml = buildTwimlResponse(state, encodedState, functionsBase, state.gatherPrompt); + + return new Response(twiml, { + status: 200, + headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" } + }); +} + +export const onRequestGet = handleVoiceResponse; +export const onRequestPost = handleVoiceResponse; diff --git a/index.html b/index.html index 60004bd..e52b44d 100644 --- a/index.html +++ b/index.html @@ -253,7 +253,7 @@

placeholder="https://your-voice-bridge.example.com" />
- Provide the HTTPS base URL of the deployed server that exposes the /api/start-call route. + Provide the HTTPS base URL of the deployed server that exposes the /api/start-call route, or leave this field blank to use the built-in GitHub Pages voice bridge.
diff --git a/ui.js b/ui.js index 52b31b1..33bbdc6 100644 --- a/ui.js +++ b/ui.js @@ -104,7 +104,7 @@ document.addEventListener("DOMContentLoaded", () => { } if (twilioStatusEl) { - updateTwilioStatus("Ready to place a call. Enter your server URL and phone number, then press Call My Phone."); + updateTwilioStatus("Ready to place a call. Enter your server URL and phone number, or leave the URL blank to use the built-in GitHub Pages voice bridge."); } if (twilioServerInput) { @@ -114,8 +114,13 @@ document.addEventListener("DOMContentLoaded", () => { } twilioServerInput.addEventListener("change", () => { const normalized = sanitizeServerUrl(twilioServerInput.value); - twilioServerInput.value = normalized; - persistValue(twilioStorageKeys.server, normalized); + if (normalized) { + twilioServerInput.value = normalized; + persistValue(twilioStorageKeys.server, normalized); + } else { + twilioServerInput.value = ""; + persistValue(twilioStorageKeys.server, ""); + } }); } @@ -161,26 +166,32 @@ document.addEventListener("DOMContentLoaded", () => { if (!twilioCallBtn || !twilioServerInput || !twilioPhoneInput) return; const rawServerUrl = sanitizeServerUrl(twilioServerInput.value); - if (!rawServerUrl) { - updateTwilioStatus("Enter the full HTTPS URL of your voice bridge server.", "error"); - twilioServerInput.focus(); - if (window.showToast) window.showToast("Voice bridge URL is required."); - return; - } + const usingBuiltInBridge = !rawServerUrl; + let serverBaseUrl = rawServerUrl; - let parsedUrl; - try { - parsedUrl = new URL(rawServerUrl); - } catch (err) { - updateTwilioStatus("The voice bridge URL is not valid. Double-check the format.", "error"); - if (window.showToast) window.showToast("Provide a valid HTTPS URL for the voice bridge."); - return; - } + if (usingBuiltInBridge) { + const origin = (window.location && window.location.origin) ? window.location.origin.trim() : ""; + if (!origin || !origin.startsWith("https://")) { + updateTwilioStatus("Built-in voice bridge is only available on HTTPS deployments. Provide your own server URL while developing locally.", "error"); + if (window.showToast) window.showToast("Built-in voice bridge requires HTTPS hosting."); + return; + } + serverBaseUrl = `${origin.replace(/\/$/, "")}/_functions`; + } else { + let parsedUrl; + try { + parsedUrl = new URL(rawServerUrl); + } catch (err) { + updateTwilioStatus("The voice bridge URL is not valid. Double-check the format.", "error"); + if (window.showToast) window.showToast("Provide a valid HTTPS URL for the voice bridge."); + return; + } - if (parsedUrl.protocol !== "https:" && parsedUrl.hostname !== "localhost") { - updateTwilioStatus("Use an HTTPS URL so Twilio can reach your server.", "error"); - if (window.showToast) window.showToast("HTTPS is required unless you are testing on localhost."); - return; + if (parsedUrl.protocol !== "https:" && parsedUrl.hostname !== "localhost") { + updateTwilioStatus("Use an HTTPS URL so Twilio can reach your server.", "error"); + if (window.showToast) window.showToast("HTTPS is required unless you are testing on localhost."); + return; + } } const phoneNumber = (twilioPhoneInput.value || "").trim(); @@ -194,13 +205,14 @@ document.addEventListener("DOMContentLoaded", () => { const initialPrompt = twilioPromptInput ? twilioPromptInput.value.trim() : ""; const voice = twilioVoiceSelect ? twilioVoiceSelect.value : "nova"; - persistValue(twilioStorageKeys.server, rawServerUrl); + persistValue(twilioStorageKeys.server, usingBuiltInBridge ? "" : serverBaseUrl); persistValue(twilioStorageKeys.phone, phoneNumber); persistValue(twilioStorageKeys.prompt, initialPrompt); persistValue(twilioStorageKeys.voice, voice); - const endpoint = `${rawServerUrl}/api/start-call`; - updateTwilioStatus("Contacting the voice bridge…", "pending"); + const normalizedBase = serverBaseUrl.replace(/\/$/, ""); + const endpoint = `${normalizedBase}/api/start-call`; + updateTwilioStatus(usingBuiltInBridge ? "Contacting the built-in voice bridge…" : "Contacting the voice bridge…", "pending"); twilioCallBtn.disabled = true; try {