Skip to content

Commit 9a92523

Browse files
authored
Merge pull request #56 from Unity-Lab-AI/codex/update-yml-for-build-and-test-with-tts
Add CI workflow and pollinations utility tests
2 parents 0b57719 + 13d7b12 commit 9a92523

8 files changed

Lines changed: 237 additions & 44 deletions

File tree

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Build and Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- master
8+
pull_request:
9+
10+
env:
11+
CI: true
12+
13+
jobs:
14+
build-and-test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout repository
18+
uses: actions/checkout@v4
19+
20+
- name: Setup Node.js
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: 20
24+
cache: npm
25+
26+
- name: Install root dependencies
27+
run: npm ci
28+
29+
- name: Run root tests
30+
run: npm test
31+
32+
- name: Install Twilio voice bridge dependencies
33+
run: npm ci
34+
working-directory: twilio-voice-app
35+
36+
- name: Run Twilio voice bridge tests
37+
run: npm test
38+
working-directory: twilio-voice-app

chat-core.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -555,12 +555,14 @@ document.addEventListener("DOMContentLoaded", () => {
555555
return;
556556
}
557557

558-
try {
559-
const res = await window.pollinationsFetch("https://text.pollinations.ai/openai", {
560-
method: "POST",
561-
headers: { "Content-Type": "application/json", Accept: "application/json" },
562-
body: JSON.stringify({ model, messages })
563-
}, { timeoutMs: 45000 });
558+
try {
559+
const apiUrl = new URL("https://text.pollinations.ai/openai");
560+
apiUrl.searchParams.set("model", model);
561+
const res = await window.pollinationsFetch(apiUrl.toString(), {
562+
method: "POST",
563+
headers: { "Content-Type": "application/json", Accept: "application/json" },
564+
body: JSON.stringify({ model, messages })
565+
}, { timeoutMs: 45000 });
564566
const data = await res.json();
565567
loadingDiv.remove();
566568
const aiContentRaw = data?.choices?.[0]?.message?.content || "";

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "Voice controlled chat interface",
55
"scripts": {
66
"start": "http-server -c-1 .",
7-
"test": "echo \"No tests specified\""
7+
"test": "node --test tests"
88
},
99
"devDependencies": {
1010
"http-server": "^14.1.1"

screensaver.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,15 @@ document.addEventListener("DOMContentLoaded", () => {
195195
const textModel = document.getElementById("model-select")?.value;
196196
const seed = generateSeed();
197197
try {
198-
const response = await window.pollinationsFetch("https://text.pollinations.ai/openai", {
198+
const modelName = (textModel || "unity").trim();
199+
const endpoint = new URL("https://text.pollinations.ai/openai");
200+
endpoint.searchParams.set("model", modelName);
201+
const response = await window.pollinationsFetch(endpoint.toString(), {
199202
method: "POST",
200203
headers: { "Content-Type": "application/json", Accept: "application/json" },
201204
cache: "no-store",
202205
body: JSON.stringify({
203-
model: textModel || "openai",
206+
model: modelName,
204207
seed,
205208
messages: [{ role: "user", content: metaPrompt }]
206209
})

tests/pollinations-utils.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert');
3+
const path = require('node:path');
4+
5+
const utilsPath = path.resolve(__dirname, '../twilio-voice-app/pollinations-utils.js');
6+
const {
7+
DEFAULT_TEXT_MODEL,
8+
DEFAULT_TTS_MODEL,
9+
DEFAULT_OPENAI_OPTIONS,
10+
sanitizeForTts,
11+
createTtsUrl,
12+
buildOpenAiUrl,
13+
createOpenAiPayload
14+
} = require(utilsPath);
15+
16+
test('default models expose unity text model and openai-audio tts model', () => {
17+
assert.strictEqual(DEFAULT_TEXT_MODEL, 'unity');
18+
assert.strictEqual(DEFAULT_TTS_MODEL, 'openai-audio');
19+
});
20+
21+
test('buildOpenAiUrl appends model query parameter', () => {
22+
const url = buildOpenAiUrl('unity');
23+
assert.strictEqual(url, 'https://text.pollinations.ai/openai?model=unity');
24+
});
25+
26+
test('createOpenAiPayload returns expected structure', () => {
27+
const messages = [{ role: 'user', content: 'Hello' }];
28+
const payload = createOpenAiPayload(messages, { model: 'unity' });
29+
assert.deepStrictEqual(payload, {
30+
model: 'unity',
31+
messages,
32+
temperature: DEFAULT_OPENAI_OPTIONS.temperature,
33+
max_output_tokens: DEFAULT_OPENAI_OPTIONS.max_output_tokens,
34+
top_p: DEFAULT_OPENAI_OPTIONS.top_p,
35+
presence_penalty: DEFAULT_OPENAI_OPTIONS.presence_penalty,
36+
frequency_penalty: DEFAULT_OPENAI_OPTIONS.frequency_penalty,
37+
stream: DEFAULT_OPENAI_OPTIONS.stream
38+
});
39+
});
40+
41+
test('createOpenAiPayload enforces array messages', () => {
42+
assert.throws(() => createOpenAiPayload(null), { name: 'TypeError' });
43+
});
44+
45+
test('sanitizeForTts compacts whitespace and truncates long text', () => {
46+
const longText = 'Hello world\nthis is a test';
47+
assert.strictEqual(sanitizeForTts(longText), 'Hello world this is a test');
48+
49+
const repeated = 'a'.repeat(500);
50+
const sanitized = sanitizeForTts(repeated);
51+
assert.ok(sanitized.endsWith('...'));
52+
assert.strictEqual(sanitized.length, 380);
53+
});
54+
55+
test('createTtsUrl encodes sanitized text and attaches defaults', () => {
56+
const url = new URL(createTtsUrl('Hello\nworld', 'nova'));
57+
assert.strictEqual(url.searchParams.get('model'), DEFAULT_TTS_MODEL);
58+
assert.strictEqual(url.searchParams.get('voice'), 'nova');
59+
assert.strictEqual(url.pathname, '/Hello%20world');
60+
});

twilio-voice-app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"type": "commonjs",
77
"scripts": {
88
"start": "node server.js",
9-
"dev": "nodemon server.js"
9+
"dev": "nodemon server.js",
10+
"test": "node --test ../tests"
1011
},
1112
"dependencies": {
1213
"dotenv": "^16.4.5",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const DEFAULT_TEXT_MODEL = 'unity';
2+
const DEFAULT_TTS_MODEL = 'openai-audio';
3+
4+
function sanitizeForTts(text) {
5+
if (!text) return '';
6+
const compact = String(text).replace(/\s+/g, ' ').trim();
7+
if (compact.length <= 380) {
8+
return compact;
9+
}
10+
return `${compact.slice(0, 377)}...`;
11+
}
12+
13+
function createTtsUrl(text, voice = 'nova', { model = DEFAULT_TTS_MODEL } = {}) {
14+
const sanitized = sanitizeForTts(text);
15+
const encoded = encodeURIComponent(sanitized);
16+
const url = new URL(`https://text.pollinations.ai/${encoded}`);
17+
if (model) {
18+
url.searchParams.set('model', model);
19+
}
20+
if (voice) {
21+
url.searchParams.set('voice', voice);
22+
}
23+
return url.toString();
24+
}
25+
26+
function buildOpenAiUrl(model = DEFAULT_TEXT_MODEL) {
27+
const url = new URL('https://text.pollinations.ai/openai');
28+
if (model) {
29+
url.searchParams.set('model', model);
30+
}
31+
return url.toString();
32+
}
33+
34+
const DEFAULT_OPENAI_OPTIONS = {
35+
temperature: 0.8,
36+
max_output_tokens: 300,
37+
top_p: 0.95,
38+
presence_penalty: 0,
39+
frequency_penalty: 0,
40+
stream: false
41+
};
42+
43+
function createOpenAiPayload(messages, options = {}) {
44+
if (!Array.isArray(messages)) {
45+
throw new TypeError('messages must be an array');
46+
}
47+
48+
const {
49+
model = DEFAULT_TEXT_MODEL,
50+
temperature = DEFAULT_OPENAI_OPTIONS.temperature,
51+
max_output_tokens = DEFAULT_OPENAI_OPTIONS.max_output_tokens,
52+
top_p = DEFAULT_OPENAI_OPTIONS.top_p,
53+
presence_penalty = DEFAULT_OPENAI_OPTIONS.presence_penalty,
54+
frequency_penalty = DEFAULT_OPENAI_OPTIONS.frequency_penalty,
55+
stream = DEFAULT_OPENAI_OPTIONS.stream
56+
} = options;
57+
58+
return {
59+
model,
60+
messages,
61+
temperature,
62+
max_output_tokens,
63+
top_p,
64+
presence_penalty,
65+
frequency_penalty,
66+
stream
67+
};
68+
}
69+
70+
module.exports = {
71+
DEFAULT_TEXT_MODEL,
72+
DEFAULT_TTS_MODEL,
73+
DEFAULT_OPENAI_OPTIONS,
74+
sanitizeForTts,
75+
createTtsUrl,
76+
buildOpenAiUrl,
77+
createOpenAiPayload
78+
};

twilio-voice-app/server.js

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ const twilio = require('twilio');
33
const { v4: uuidv4 } = require('uuid');
44
const path = require('path');
55
const dotenv = require('dotenv');
6+
const {
7+
DEFAULT_TEXT_MODEL: BASE_TEXT_MODEL,
8+
DEFAULT_TTS_MODEL: BASE_TTS_MODEL,
9+
createTtsUrl,
10+
buildOpenAiUrl,
11+
createOpenAiPayload
12+
} = require('./pollinations-utils');
613

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

@@ -17,6 +24,8 @@ const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID;
1724
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN;
1825
const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER;
1926
const DEFAULT_VOICE = process.env.POLLINATIONS_VOICE || 'nova';
27+
const DEFAULT_TEXT_MODEL = process.env.POLLINATIONS_TEXT_MODEL || BASE_TEXT_MODEL;
28+
const DEFAULT_TTS_MODEL = process.env.POLLINATIONS_TTS_MODEL || BASE_TTS_MODEL;
2029

2130
const hasTwilioCredentials =
2231
Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN && TWILIO_PHONE_NUMBER);
@@ -44,39 +53,15 @@ const client = hasTwilioCredentials
4453
? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
4554
: null;
4655

47-
function sanitizeForTts(text) {
48-
if (!text) return '';
49-
const compact = text.replace(/\s+/g, ' ').trim();
50-
if (compact.length <= 380) return compact;
51-
return `${compact.slice(0, 377)}...`;
52-
}
53-
54-
function createTtsUrl(text, voice = DEFAULT_VOICE) {
55-
const sanitized = sanitizeForTts(text);
56-
const encoded = encodeURIComponent(sanitized);
57-
const url = new URL(`https://text.pollinations.ai/${encoded}`);
58-
url.searchParams.set('model', 'openai-audio');
59-
url.searchParams.set('voice', voice);
60-
return url.toString();
61-
}
62-
6356
async function fetchPollinationsResponse(session, userMessage) {
6457
if (userMessage && userMessage.trim()) {
6558
session.messages.push({ role: 'user', content: userMessage.trim() });
6659
}
6760

68-
const payload = {
69-
model: 'openai',
70-
messages: session.messages,
71-
temperature: 0.8,
72-
max_output_tokens: 300,
73-
top_p: 0.95,
74-
presence_penalty: 0,
75-
frequency_penalty: 0,
76-
stream: false
77-
};
61+
const sessionModel = session.model || DEFAULT_TEXT_MODEL;
62+
const payload = createOpenAiPayload(session.messages, { model: sessionModel });
7863

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

101-
function createSession(phoneNumber, initialVoice = DEFAULT_VOICE) {
86+
function createSession(phoneNumber, initialVoice = DEFAULT_VOICE, initialModel = DEFAULT_TEXT_MODEL) {
10287
const id = uuidv4();
10388
const session = {
10489
id,
10590
phoneNumber,
10691
voice: initialVoice,
92+
model: initialModel,
10793
messages: [{ role: 'system', content: SYSTEM_PROMPT }],
10894
lastAssistant: null
10995
};
@@ -123,7 +109,7 @@ function buildVoiceResponse(session, twiml, promptMessage, gatherPrompt) {
123109
return twiml;
124110
}
125111

126-
const audioUrl = createTtsUrl(responseMessage, session.voice);
112+
const audioUrl = createTtsUrl(responseMessage, session.voice, { model: DEFAULT_TTS_MODEL });
127113
twiml.play(audioUrl);
128114

129115
const gather = twiml.gather({
@@ -167,7 +153,7 @@ async function startPhoneCall(session) {
167153

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

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

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

272-
app.listen(PORT, () => {
273-
console.log(`Twilio voice bridge listening on port ${PORT}`);
274-
});
258+
function startServer(port = PORT) {
259+
return app.listen(port, () => {
260+
console.log(`Twilio voice bridge listening on port ${port}`);
261+
});
262+
}
263+
264+
if (require.main === module) {
265+
startServer();
266+
}
267+
268+
module.exports = {
269+
app,
270+
startServer,
271+
fetchPollinationsResponse,
272+
createSession,
273+
buildGatherAction,
274+
buildVoiceResponse,
275+
startPhoneCall,
276+
getSession,
277+
sessions,
278+
hasTwilioCredentials,
279+
DEFAULT_VOICE,
280+
DEFAULT_TEXT_MODEL,
281+
DEFAULT_TTS_MODEL,
282+
buildOpenAiUrl,
283+
createOpenAiPayload,
284+
createTtsUrl
285+
};

0 commit comments

Comments
 (0)