From 13d7b12a414332ded761075bc580150e9ff05636 Mon Sep 17 00:00:00 2001 From: G-Fourteen Date: Tue, 16 Sep 2025 08:31:57 -0600 Subject: [PATCH] Add CI workflow and pollinations utility tests --- .github/workflows/ci.yml | 38 +++++++++++++ chat-core.js | 14 +++-- package.json | 2 +- screensaver.js | 7 ++- tests/pollinations-utils.test.js | 60 +++++++++++++++++++ twilio-voice-app/package.json | 3 +- twilio-voice-app/pollinations-utils.js | 78 +++++++++++++++++++++++++ twilio-voice-app/server.js | 79 +++++++++++++++----------- 8 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/pollinations-utils.test.js create mode 100644 twilio-voice-app/pollinations-utils.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d7ffaad --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/chat-core.js b/chat-core.js index c601132..2495ffc 100644 --- a/chat-core.js +++ b/chat-core.js @@ -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 || ""; diff --git a/package.json b/package.json index af3c3a8..a93e9a6 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/screensaver.js b/screensaver.js index d1dd07c..6004669 100644 --- a/screensaver.js +++ b/screensaver.js @@ -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 }] }) diff --git a/tests/pollinations-utils.test.js b/tests/pollinations-utils.test.js new file mode 100644 index 0000000..9ebc9fa --- /dev/null +++ b/tests/pollinations-utils.test.js @@ -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'); +}); diff --git a/twilio-voice-app/package.json b/twilio-voice-app/package.json index fcf25c2..c2444e8 100644 --- a/twilio-voice-app/package.json +++ b/twilio-voice-app/package.json @@ -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", diff --git a/twilio-voice-app/pollinations-utils.js b/twilio-voice-app/pollinations-utils.js new file mode 100644 index 0000000..39a11e7 --- /dev/null +++ b/twilio-voice-app/pollinations-utils.js @@ -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 +}; diff --git a/twilio-voice-app/server.js b/twilio-voice-app/server.js index fc42295..218f52d 100644 --- a/twilio-voice-app/server.js +++ b/twilio-voice-app/server.js @@ -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') }); @@ -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); @@ -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) @@ -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 }; @@ -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({ @@ -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.' }); } @@ -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()) { @@ -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 +};