Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Build and Test

on:
push:
branches:
- main
- master
pull_request:

env:
CI: true

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install root dependencies
run: npm ci

- name: Run root tests
run: npm test

- name: Install Twilio voice bridge dependencies
run: npm ci
working-directory: twilio-voice-app

- name: Run Twilio voice bridge tests
run: npm test
working-directory: twilio-voice-app
14 changes: 8 additions & 6 deletions chat-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,12 +555,14 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}

try {
const res = await window.pollinationsFetch("https://text.pollinations.ai/openai", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ model, messages })
}, { timeoutMs: 45000 });
try {
const apiUrl = new URL("https://text.pollinations.ai/openai");
apiUrl.searchParams.set("model", model);
const res = await window.pollinationsFetch(apiUrl.toString(), {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ model, messages })
}, { timeoutMs: 45000 });
const data = await res.json();
loadingDiv.remove();
const aiContentRaw = data?.choices?.[0]?.message?.content || "";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Voice controlled chat interface",
"scripts": {
"start": "http-server -c-1 .",
"test": "echo \"No tests specified\""
"test": "node --test tests"
},
"devDependencies": {
"http-server": "^14.1.1"
Expand Down
7 changes: 5 additions & 2 deletions screensaver.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,15 @@ document.addEventListener("DOMContentLoaded", () => {
const textModel = document.getElementById("model-select")?.value;
const seed = generateSeed();
try {
const response = await window.pollinationsFetch("https://text.pollinations.ai/openai", {
const modelName = (textModel || "unity").trim();
const endpoint = new URL("https://text.pollinations.ai/openai");
endpoint.searchParams.set("model", modelName);
const response = await window.pollinationsFetch(endpoint.toString(), {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
cache: "no-store",
body: JSON.stringify({
model: textModel || "openai",
model: modelName,
seed,
messages: [{ role: "user", content: metaPrompt }]
})
Expand Down
60 changes: 60 additions & 0 deletions tests/pollinations-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const test = require('node:test');
const assert = require('node:assert');
const path = require('node:path');

const utilsPath = path.resolve(__dirname, '../twilio-voice-app/pollinations-utils.js');
const {
DEFAULT_TEXT_MODEL,
DEFAULT_TTS_MODEL,
DEFAULT_OPENAI_OPTIONS,
sanitizeForTts,
createTtsUrl,
buildOpenAiUrl,
createOpenAiPayload
} = require(utilsPath);

test('default models expose unity text model and openai-audio tts model', () => {
assert.strictEqual(DEFAULT_TEXT_MODEL, 'unity');
assert.strictEqual(DEFAULT_TTS_MODEL, 'openai-audio');
});

test('buildOpenAiUrl appends model query parameter', () => {
const url = buildOpenAiUrl('unity');
assert.strictEqual(url, 'https://text.pollinations.ai/openai?model=unity');
});

test('createOpenAiPayload returns expected structure', () => {
const messages = [{ role: 'user', content: 'Hello' }];
const payload = createOpenAiPayload(messages, { model: 'unity' });
assert.deepStrictEqual(payload, {
model: 'unity',
messages,
temperature: DEFAULT_OPENAI_OPTIONS.temperature,
max_output_tokens: DEFAULT_OPENAI_OPTIONS.max_output_tokens,
top_p: DEFAULT_OPENAI_OPTIONS.top_p,
presence_penalty: DEFAULT_OPENAI_OPTIONS.presence_penalty,
frequency_penalty: DEFAULT_OPENAI_OPTIONS.frequency_penalty,
stream: DEFAULT_OPENAI_OPTIONS.stream
});
});

test('createOpenAiPayload enforces array messages', () => {
assert.throws(() => createOpenAiPayload(null), { name: 'TypeError' });
});

test('sanitizeForTts compacts whitespace and truncates long text', () => {
const longText = 'Hello world\nthis is a test';
assert.strictEqual(sanitizeForTts(longText), 'Hello world this is a test');

const repeated = 'a'.repeat(500);
const sanitized = sanitizeForTts(repeated);
assert.ok(sanitized.endsWith('...'));
assert.strictEqual(sanitized.length, 380);
});

test('createTtsUrl encodes sanitized text and attaches defaults', () => {
const url = new URL(createTtsUrl('Hello\nworld', 'nova'));
assert.strictEqual(url.searchParams.get('model'), DEFAULT_TTS_MODEL);
assert.strictEqual(url.searchParams.get('voice'), 'nova');
assert.strictEqual(url.pathname, '/Hello%20world');
});
3 changes: 2 additions & 1 deletion twilio-voice-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"type": "commonjs",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "node --test ../tests"
},
"dependencies": {
"dotenv": "^16.4.5",
Expand Down
78 changes: 78 additions & 0 deletions twilio-voice-app/pollinations-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const DEFAULT_TEXT_MODEL = 'unity';
const DEFAULT_TTS_MODEL = 'openai-audio';

function sanitizeForTts(text) {
if (!text) return '';
const compact = String(text).replace(/\s+/g, ' ').trim();
if (compact.length <= 380) {
return compact;
}
return `${compact.slice(0, 377)}...`;
}

function createTtsUrl(text, voice = 'nova', { model = DEFAULT_TTS_MODEL } = {}) {
const sanitized = sanitizeForTts(text);
const encoded = encodeURIComponent(sanitized);
const url = new URL(`https://text.pollinations.ai/${encoded}`);
if (model) {
url.searchParams.set('model', model);
}
if (voice) {
url.searchParams.set('voice', voice);
}
return url.toString();
}

function buildOpenAiUrl(model = DEFAULT_TEXT_MODEL) {
const url = new URL('https://text.pollinations.ai/openai');
if (model) {
url.searchParams.set('model', model);
}
return url.toString();
}

const DEFAULT_OPENAI_OPTIONS = {
temperature: 0.8,
max_output_tokens: 300,
top_p: 0.95,
presence_penalty: 0,
frequency_penalty: 0,
stream: false
};

function createOpenAiPayload(messages, options = {}) {
if (!Array.isArray(messages)) {
throw new TypeError('messages must be an array');
}

const {
model = DEFAULT_TEXT_MODEL,
temperature = DEFAULT_OPENAI_OPTIONS.temperature,
max_output_tokens = DEFAULT_OPENAI_OPTIONS.max_output_tokens,
top_p = DEFAULT_OPENAI_OPTIONS.top_p,
presence_penalty = DEFAULT_OPENAI_OPTIONS.presence_penalty,
frequency_penalty = DEFAULT_OPENAI_OPTIONS.frequency_penalty,
stream = DEFAULT_OPENAI_OPTIONS.stream
} = options;

return {
model,
messages,
temperature,
max_output_tokens,
top_p,
presence_penalty,
frequency_penalty,
stream
};
}

module.exports = {
DEFAULT_TEXT_MODEL,
DEFAULT_TTS_MODEL,
DEFAULT_OPENAI_OPTIONS,
sanitizeForTts,
createTtsUrl,
buildOpenAiUrl,
createOpenAiPayload
};
79 changes: 45 additions & 34 deletions twilio-voice-app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ const twilio = require('twilio');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const dotenv = require('dotenv');
const {
DEFAULT_TEXT_MODEL: BASE_TEXT_MODEL,
DEFAULT_TTS_MODEL: BASE_TTS_MODEL,
createTtsUrl,
buildOpenAiUrl,
createOpenAiPayload
} = require('./pollinations-utils');

dotenv.config({ path: path.resolve(__dirname, '.env') });

Expand All @@ -17,6 +24,8 @@ const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID;
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN;
const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER;
const DEFAULT_VOICE = process.env.POLLINATIONS_VOICE || 'nova';
const DEFAULT_TEXT_MODEL = process.env.POLLINATIONS_TEXT_MODEL || BASE_TEXT_MODEL;
const DEFAULT_TTS_MODEL = process.env.POLLINATIONS_TTS_MODEL || BASE_TTS_MODEL;

const hasTwilioCredentials =
Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN && TWILIO_PHONE_NUMBER);
Expand Down Expand Up @@ -44,39 +53,15 @@ const client = hasTwilioCredentials
? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
: null;

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 = DEFAULT_VOICE) {
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();
}

async function fetchPollinationsResponse(session, userMessage) {
if (userMessage && userMessage.trim()) {
session.messages.push({ role: 'user', content: userMessage.trim() });
}

const payload = {
model: 'openai',
messages: session.messages,
temperature: 0.8,
max_output_tokens: 300,
top_p: 0.95,
presence_penalty: 0,
frequency_penalty: 0,
stream: false
};
const sessionModel = session.model || DEFAULT_TEXT_MODEL;
const payload = createOpenAiPayload(session.messages, { model: sessionModel });

const response = await fetchImpl('https://text.pollinations.ai/openai', {
const response = await fetchImpl(buildOpenAiUrl(sessionModel), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
Expand All @@ -98,12 +83,13 @@ async function fetchPollinationsResponse(session, userMessage) {
return assistantMessage;
}

function createSession(phoneNumber, initialVoice = DEFAULT_VOICE) {
function createSession(phoneNumber, initialVoice = DEFAULT_VOICE, initialModel = DEFAULT_TEXT_MODEL) {
const id = uuidv4();
const session = {
id,
phoneNumber,
voice: initialVoice,
model: initialModel,
messages: [{ role: 'system', content: SYSTEM_PROMPT }],
lastAssistant: null
};
Expand All @@ -123,7 +109,7 @@ function buildVoiceResponse(session, twiml, promptMessage, gatherPrompt) {
return twiml;
}

const audioUrl = createTtsUrl(responseMessage, session.voice);
const audioUrl = createTtsUrl(responseMessage, session.voice, { model: DEFAULT_TTS_MODEL });
twiml.play(audioUrl);

const gather = twiml.gather({
Expand Down Expand Up @@ -167,7 +153,7 @@ async function startPhoneCall(session) {

app.post('/api/start-call', async (req, res) => {
try {
const { phoneNumber, initialPrompt, voice } = req.body || {};
const { phoneNumber, initialPrompt, voice, model } = req.body || {};
if (!phoneNumber || typeof phoneNumber !== 'string') {
return res.status(400).json({ error: 'A destination phoneNumber is required.' });
}
Expand All @@ -178,7 +164,7 @@ app.post('/api/start-call', async (req, res) => {
return res.status(500).json({ error: 'PUBLIC_SERVER_URL is not configured on the server.' });
}

const session = createSession(phoneNumber.trim(), voice || DEFAULT_VOICE);
const session = createSession(phoneNumber.trim(), voice || DEFAULT_VOICE, model || DEFAULT_TEXT_MODEL);
const gatherPrompt = 'After the message, speak your reply and stay on the line for the assistant to respond.';

if (initialPrompt && initialPrompt.trim()) {
Expand Down Expand Up @@ -269,6 +255,31 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: 'Internal server error.' });
});

app.listen(PORT, () => {
console.log(`Twilio voice bridge listening on port ${PORT}`);
});
function startServer(port = PORT) {
return app.listen(port, () => {
console.log(`Twilio voice bridge listening on port ${port}`);
});
}

if (require.main === module) {
startServer();
}

module.exports = {
app,
startServer,
fetchPollinationsResponse,
createSession,
buildGatherAction,
buildVoiceResponse,
startPhoneCall,
getSession,
sessions,
hasTwilioCredentials,
DEFAULT_VOICE,
DEFAULT_TEXT_MODEL,
DEFAULT_TTS_MODEL,
buildOpenAiUrl,
createOpenAiPayload,
createTtsUrl
};