agentvibes-avatars 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +34 -0
- package/README.md +77 -0
- package/avatars.json +35 -0
- package/bin/cli.js +109 -0
- package/package.json +40 -0
- package/public/cosmic.html +539 -0
- package/public/gallery.html +77 -0
- package/public/index.html +484 -0
- package/server.js +286 -0
package/server.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// talking-head/server.js
|
|
4
|
+
// AgentVibes TalkingHead server — pure Node.js, no npm deps.
|
|
5
|
+
//
|
|
6
|
+
// Endpoints:
|
|
7
|
+
// GET / → serves index.html
|
|
8
|
+
// GET /avatars.json → avatar config
|
|
9
|
+
// GET /audio/:file → serves temp TTS audio files
|
|
10
|
+
// GET /events → SSE stream (browser connects here)
|
|
11
|
+
// GET /health → 200 OK (used by play-tts.ps1 to probe)
|
|
12
|
+
// POST /speak → {audioBase64, voice, project, origin, llm} → broadcasts to browser
|
|
13
|
+
// GET /has-browser → {"connected": true/false}
|
|
14
|
+
// POST /reload → broadcast reload signal
|
|
15
|
+
// POST /clientlog → browser debug lines
|
|
16
|
+
//
|
|
17
|
+
// Security model: binds 127.0.0.1 only. State-changing endpoints reject any
|
|
18
|
+
// request carrying a non-loopback Origin header (blocks CSRF from a drive-by
|
|
19
|
+
// browser tab). Server-side callers (curl / Invoke-RestMethod) send no Origin
|
|
20
|
+
// and are allowed. Request bodies are size-capped to prevent OOM/disk DoS.
|
|
21
|
+
//
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const http = require('http');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
const PORT = parseInt(process.env.TALKING_HEAD_PORT || '3747', 10);
|
|
31
|
+
const AUDIO_DIR = path.join(__dirname, 'public', 'audio');
|
|
32
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
33
|
+
|
|
34
|
+
const MAX_BODY = 8 * 1024 * 1024; // 8 MB hard cap on any request body
|
|
35
|
+
const MAX_LOG = 4 * 1024; // 4 KB cap on /clientlog payloads
|
|
36
|
+
|
|
37
|
+
// Ensure audio temp dir exists
|
|
38
|
+
fs.mkdirSync(AUDIO_DIR, { recursive: true });
|
|
39
|
+
|
|
40
|
+
// SSE clients: Map<id, res>
|
|
41
|
+
const sseClients = new Map();
|
|
42
|
+
|
|
43
|
+
// Scheduled cleanup timers for audio files: Map<filename, timer>
|
|
44
|
+
const audioCleanupTimers = new Map();
|
|
45
|
+
|
|
46
|
+
// CSRF guard: requests from another website carry a foreign Origin header.
|
|
47
|
+
// No Origin (server-side curl/PowerShell) or a loopback Origin is allowed.
|
|
48
|
+
function originOk(req) {
|
|
49
|
+
const o = req.headers.origin;
|
|
50
|
+
if (!o) return true;
|
|
51
|
+
try { const u = new URL(o); return u.hostname === '127.0.0.1' || u.hostname === 'localhost'; }
|
|
52
|
+
catch { return false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function broadcastSSE(payload) {
|
|
56
|
+
const data = `data: ${JSON.stringify(payload)}\n\n`;
|
|
57
|
+
for (const res of sseClients.values()) {
|
|
58
|
+
try { res.write(data); } catch {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function serveFile(filePath, res) {
|
|
63
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
64
|
+
const mimeTypes = {
|
|
65
|
+
'.html': 'text/html; charset=utf-8',
|
|
66
|
+
'.js': 'application/javascript',
|
|
67
|
+
'.mjs': 'application/javascript',
|
|
68
|
+
'.css': 'text/css',
|
|
69
|
+
'.json': 'application/json',
|
|
70
|
+
'.wav': 'audio/wav',
|
|
71
|
+
'.mp3': 'audio/mpeg',
|
|
72
|
+
'.glb': 'model/gltf-binary',
|
|
73
|
+
};
|
|
74
|
+
const mime = mimeTypes[ext] || 'application/octet-stream';
|
|
75
|
+
|
|
76
|
+
fs.stat(filePath, (err, stat) => {
|
|
77
|
+
if (err || !stat.isFile()) { res.writeHead(404); res.end('Not found'); return; }
|
|
78
|
+
res.writeHead(200, {
|
|
79
|
+
'Content-Type': mime,
|
|
80
|
+
'Content-Length': stat.size,
|
|
81
|
+
'Cache-Control': 'no-cache',
|
|
82
|
+
});
|
|
83
|
+
const stream = fs.createReadStream(filePath);
|
|
84
|
+
// Without this, a file deleted mid-read (120s cleanup) or any read error
|
|
85
|
+
// emits an unhandled 'error' event that crashes the whole server.
|
|
86
|
+
stream.on('error', () => { try { res.destroy(); } catch {} });
|
|
87
|
+
stream.pipe(res);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseBody(req) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const chunks = []; let size = 0;
|
|
94
|
+
req.on('data', c => {
|
|
95
|
+
size += c.length;
|
|
96
|
+
if (size > MAX_BODY) { reject(new Error('body too large')); try { req.destroy(); } catch {} return; }
|
|
97
|
+
chunks.push(c);
|
|
98
|
+
});
|
|
99
|
+
req.on('end', () => {
|
|
100
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
101
|
+
catch (e) { reject(e); }
|
|
102
|
+
});
|
|
103
|
+
req.on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Security: validate that a filename is safe (no path traversal)
|
|
108
|
+
function safeName(name) {
|
|
109
|
+
return /^[a-zA-Z0-9_\-]+\.wav$/.test(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const server = http.createServer(async (req, res) => {
|
|
113
|
+
const urlObj = new URL(req.url, `http://localhost:${PORT}`);
|
|
114
|
+
const urlPath = urlObj.pathname;
|
|
115
|
+
|
|
116
|
+
// CORS preflight — echo a loopback Origin only; foreign origins get no ACAO
|
|
117
|
+
// so their preflight fails and the browser never sends the real request.
|
|
118
|
+
if (req.method === 'OPTIONS') {
|
|
119
|
+
const h = { 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' };
|
|
120
|
+
if (originOk(req) && req.headers.origin) h['Access-Control-Allow-Origin'] = req.headers.origin;
|
|
121
|
+
res.writeHead(204, h);
|
|
122
|
+
res.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Health check
|
|
127
|
+
if (req.method === 'GET' && urlPath === '/health') {
|
|
128
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
129
|
+
res.end(JSON.stringify({ ok: true, clients: sseClients.size }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Reload — broadcast a reload signal so open browsers refresh in place
|
|
134
|
+
if (req.method === 'POST' && urlPath === '/reload') {
|
|
135
|
+
if (!originOk(req)) { res.writeHead(403); res.end(); return; }
|
|
136
|
+
broadcastSSE({ type: 'reload' });
|
|
137
|
+
res.writeHead(204);
|
|
138
|
+
res.end();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Client log — browser posts debug lines here so they appear in server.log
|
|
143
|
+
if (req.method === 'POST' && urlPath === '/clientlog') {
|
|
144
|
+
if (!originOk(req)) { res.writeHead(403); res.end(); return; }
|
|
145
|
+
let body = '';
|
|
146
|
+
req.on('data', c => { body += c; if (body.length > MAX_LOG) { try { req.destroy(); } catch {} } });
|
|
147
|
+
req.on('end', () => {
|
|
148
|
+
// Strip CR/LF so a payload can't forge extra log lines (log injection).
|
|
149
|
+
const clean = body.slice(0, MAX_LOG).replace(/[\r\n]+/g, ' ');
|
|
150
|
+
console.log('[CLIENT] ' + clean);
|
|
151
|
+
res.writeHead(204); res.end();
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Browser connection count
|
|
157
|
+
if (req.method === 'GET' && urlPath === '/has-browser') {
|
|
158
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end(JSON.stringify({ connected: sseClients.size > 0 }));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// SSE stream
|
|
164
|
+
if (req.method === 'GET' && urlPath === '/events') {
|
|
165
|
+
const clientId = crypto.randomBytes(4).toString('hex');
|
|
166
|
+
res.writeHead(200, {
|
|
167
|
+
'Content-Type': 'text/event-stream',
|
|
168
|
+
'Cache-Control': 'no-cache',
|
|
169
|
+
'Connection': 'keep-alive',
|
|
170
|
+
'X-Accel-Buffering': 'no',
|
|
171
|
+
});
|
|
172
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
|
|
173
|
+
sseClients.set(clientId, res);
|
|
174
|
+
console.log(`[SSE] Browser connected: ${clientId} (total: ${sseClients.size})`);
|
|
175
|
+
|
|
176
|
+
// Keepalive ping every 25s
|
|
177
|
+
const ping = setInterval(() => {
|
|
178
|
+
try { res.write(': ping\n\n'); } catch { clearInterval(ping); }
|
|
179
|
+
}, 25000);
|
|
180
|
+
|
|
181
|
+
req.on('close', () => {
|
|
182
|
+
clearInterval(ping);
|
|
183
|
+
sseClients.delete(clientId);
|
|
184
|
+
console.log(`[SSE] Browser disconnected: ${clientId} (total: ${sseClients.size})`);
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// POST /speak — receive audio from play-tts.ps1 / forward-to-avatar.sh
|
|
190
|
+
if (req.method === 'POST' && urlPath === '/speak') {
|
|
191
|
+
if (!originOk(req)) { res.writeHead(403); res.end(); return; }
|
|
192
|
+
let body;
|
|
193
|
+
try { body = await parseBody(req); }
|
|
194
|
+
catch {
|
|
195
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify({ error: 'Invalid JSON or body too large' }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const hasBrowser = sseClients.size > 0;
|
|
201
|
+
const payload = {
|
|
202
|
+
type: 'speak',
|
|
203
|
+
audioUrl: null,
|
|
204
|
+
text: body.text || '',
|
|
205
|
+
voice: body.voice || '',
|
|
206
|
+
project: body.project || '',
|
|
207
|
+
origin: body.origin || 'remote',
|
|
208
|
+
llm: body.llm || '',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (hasBrowser && body.audioBase64) {
|
|
212
|
+
const buf = Buffer.from(String(body.audioBase64), 'base64');
|
|
213
|
+
if (buf.length === 0 || buf.length > MAX_BODY) {
|
|
214
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
215
|
+
res.end(JSON.stringify({ error: 'audio too large or empty' }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const fileName = `${crypto.randomBytes(8).toString('hex')}.wav`;
|
|
219
|
+
const filePath = path.join(AUDIO_DIR, fileName);
|
|
220
|
+
// Async write — never block the event loop. Broadcast only after the
|
|
221
|
+
// file is on disk so the browser can't 404 fetching it.
|
|
222
|
+
fs.writeFile(filePath, buf, (err) => {
|
|
223
|
+
if (err) { console.error('[SPEAK] Audio save failed:', err.message); return; }
|
|
224
|
+
payload.audioUrl = `/audio/${fileName}`;
|
|
225
|
+
broadcastSSE(payload);
|
|
226
|
+
const timer = setTimeout(() => { fs.unlink(filePath, () => {}); audioCleanupTimers.delete(fileName); }, 120000);
|
|
227
|
+
audioCleanupTimers.set(fileName, timer);
|
|
228
|
+
console.log(`[SPEAK] Queued ${fileName} for ${sseClients.size} browser(s) | project=${body.project || '-'} voice=${body.voice || '-'}`);
|
|
229
|
+
});
|
|
230
|
+
} else if (hasBrowser) {
|
|
231
|
+
// No audio bytes — just notify browser (e.g. SAPI plays inline, no file)
|
|
232
|
+
broadcastSSE(payload);
|
|
233
|
+
} else {
|
|
234
|
+
console.log('[SPEAK] No browser connected — skipping SSE broadcast');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({ browserConnected: hasBrowser }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Static files
|
|
243
|
+
if (req.method === 'GET') {
|
|
244
|
+
// Audio files
|
|
245
|
+
const audioMatch = urlPath.match(/^\/audio\/([a-zA-Z0-9_\-]+\.wav)$/);
|
|
246
|
+
if (audioMatch && safeName(audioMatch[1])) {
|
|
247
|
+
serveFile(path.join(AUDIO_DIR, audioMatch[1]), res);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// avatars.json
|
|
252
|
+
if (urlPath === '/avatars.json') {
|
|
253
|
+
serveFile(path.join(__dirname, 'avatars.json'), res);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// index.html for /
|
|
258
|
+
if (urlPath === '/' || urlPath === '/index.html') {
|
|
259
|
+
serveFile(path.join(PUBLIC_DIR, 'index.html'), res);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Other public files
|
|
264
|
+
const safePubPath = path.resolve(PUBLIC_DIR, urlPath.replace(/^\//, ''));
|
|
265
|
+
if (safePubPath.startsWith(PUBLIC_DIR + path.sep) || safePubPath === PUBLIC_DIR) {
|
|
266
|
+
serveFile(safePubPath, res);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
res.writeHead(404);
|
|
272
|
+
res.end('Not found');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
276
|
+
console.log(`[TalkingHead] Server listening on http://127.0.0.1:${PORT}`);
|
|
277
|
+
console.log(`[TalkingHead] Open in browser: http://localhost:${PORT}`);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
server.on('error', err => {
|
|
281
|
+
if (err.code === 'EADDRINUSE') {
|
|
282
|
+
console.error(`[TalkingHead] Port ${PORT} already in use — server may already be running.`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
});
|