-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgemini-session.js
More file actions
459 lines (402 loc) · 16.1 KB
/
Copy pathgemini-session.js
File metadata and controls
459 lines (402 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
/**
* ShatterGlass — Gemini Live API Session Manager
*
* Manages bidirectional streaming sessions between the Node.js backend
* and Google's Gemini Live API using the @google/genai SDK.
*
* Supports two auth modes:
* 1. Google AI Studio: Set GEMINI_API_KEY in .env
* 2. Vertex AI: Set GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION in .env
*/
import { GoogleGenAI, Modality } from '@google/genai';
import { MODEL_NAME, TEXT_MODEL_NAME, getSystemPrompt, buildSystemPrompt } from './config.js';
// ── Initialize SDK (supports both API key and Vertex AI) ─────
let ai;
const useVertexAI = process.env.GOOGLE_CLOUD_PROJECT && process.env.GOOGLE_CLOUD_LOCATION;
if (useVertexAI) {
// Vertex AI auth — uses ADC (Application Default Credentials)
ai = new GoogleGenAI({
vertexai: true,
project: process.env.GOOGLE_CLOUD_PROJECT,
location: process.env.GOOGLE_CLOUD_LOCATION,
});
console.log(`[Gemini] Using Vertex AI (project: ${process.env.GOOGLE_CLOUD_PROJECT}, location: ${process.env.GOOGLE_CLOUD_LOCATION})`);
} else if (process.env.GEMINI_API_KEY) {
// Google AI Studio auth — uses API key
ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
console.log('[Gemini] Using Google AI Studio (API key)');
} else {
console.error('[Gemini] No auth configured! Set GEMINI_API_KEY or GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION');
}
/**
* Creates a Gemini Live API session and wires it to a WebSocket client.
*/
export async function createGeminiSession(ws, handlers = {}, mode = 'pitch', persona = '', context = '') {
if (!ai) {
throw new Error(
'No Gemini auth configured. Set GEMINI_API_KEY (AI Studio) or GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION (Vertex AI) in .env'
);
}
// Vertex AI uses different model names than Gemini Live API
let modelName = process.env.GEMINI_MODEL || MODEL_NAME;
if (useVertexAI && modelName.includes('preview')) {
// Auto-map: gemini-2.5-flash-native-audio-preview-12-2025 → gemini-live-2.5-flash-native-audio
const vertexModel = 'gemini-live-2.5-flash-native-audio';
console.log(`[Gemini] Vertex AI: mapping model ${modelName} → ${vertexModel}`);
modelName = vertexModel;
}
const isNativeAudio = modelName.toLowerCase().includes('native-audio');
const systemPrompt = buildSystemPrompt(mode, persona, context);
console.log(`[Gemini] Creating live session with model: ${modelName}`);
console.log(`[Gemini] Auth: ${useVertexAI ? 'Vertex AI' : 'API Key'}`);
console.log(`[Gemini] Native audio model: ${isNativeAudio}`);
console.log(`[Gemini] Mode: ${mode}`);
// ── Build config ───────────────────────────────────────────
// IMPORTANT: Only use fields supported by the raw Gemini API.
// ADK Python RunConfig fields (proactivity, enable_affective_dialog) are
// NOT supported by the @google/genai JS SDK and will cause immediate
// session closure with "Cannot find field" errors.
const config = {
responseModalities: [Modality.AUDIO],
systemInstruction: {
parts: [{ text: systemPrompt }],
},
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: 'Orus',
},
},
},
// Google Search grounding — enables live market data lookup for claim verification
tools: [{ googleSearch: {} }],
};
// Transcription (native audio models only)
if (isNativeAudio) {
config.inputAudioTranscription = {};
config.outputAudioTranscription = {};
}
// Context window compression — unlimited session duration
config.contextWindowCompression = {
triggerTokens: 100000,
slidingWindow: {
targetTokens: 80000,
},
};
// Session resumption — transparent reconnection past 10min timeout
config.sessionResumption = {};
let session = null;
let isOpen = false;
let closeRequested = false;
// ── Connect ────────────────────────────────────────────────
try {
session = await ai.live.connect({
model: modelName,
config,
callbacks: {
onopen: () => {
console.log('[Gemini] Live session opened');
isOpen = true;
handlers.onOpen?.();
},
onmessage: (message) => {
try {
handleGeminiMessage(ws, message, handlers);
} catch (err) {
console.error('[Gemini] Error handling message:', err.message);
}
},
onerror: (error) => {
const msg = error?.message || String(error);
console.error('[Gemini] Session error:', msg);
isOpen = false;
handlers.onError?.(error);
},
onclose: (event) => {
const reason = event?.reason || event?.code || 'unknown';
console.error(`[Gemini] Session closed: ${reason}`);
isOpen = false;
// Provide actionable error messages
if (typeof reason === 'string') {
if (reason.includes('API key not valid')) {
console.error('[Gemini] ❌ Your API key is invalid. Regenerate at https://aistudio.google.com/apikey');
console.error('[Gemini] Or switch to Vertex AI: set GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION in .env');
} else if (reason.includes('Cannot find field')) {
console.error('[Gemini] ❌ Config field not supported by the API. Check TROUBLESHOOTING.md Section 1');
} else if (reason.includes('model') || reason.includes('not found')) {
console.error(`[Gemini] ❌ Model "${modelName}" not available. Check TROUBLESHOOTING.md Section 7`);
}
}
if (!closeRequested) {
handlers.onClose?.(event);
}
},
},
});
console.log('[Gemini] Connected successfully');
} catch (err) {
console.error('[Gemini] Failed to connect:', err.message);
if (err.message?.includes('API key')) {
console.error('[Gemini] ❌ API key rejected. Get a new one at https://aistudio.google.com/apikey');
}
throw err;
}
// ── Return session control object ──────────────────────────
return {
sendAudio(pcmBuffer) {
if (!isOpen || !session) return;
try {
const base64Audio = Buffer.from(pcmBuffer).toString('base64');
session.sendRealtimeInput({
media: {
data: base64Audio,
mimeType: 'audio/pcm;rate=16000',
},
});
} catch (err) {
console.error('[Gemini] Error sending audio:', err.message);
}
},
sendImage(base64Data, mimeType = 'image/jpeg') {
if (!isOpen || !session) return;
try {
session.sendRealtimeInput({
media: {
data: base64Data,
mimeType,
},
});
} catch (err) {
console.error('[Gemini] Error sending image:', err.message);
}
},
sendText(text) {
if (!isOpen || !session) return;
try {
session.sendClientContent({
turns: [{
role: 'user',
parts: [{ text }],
}],
});
} catch (err) {
console.error('[Gemini] Error sending text:', err.message);
}
},
close() {
closeRequested = true;
if (session) {
try {
isOpen = false;
session.close();
console.log('[Gemini] Session closed by server');
} catch (err) {
console.error('[Gemini] Error closing session:', err.message);
}
session = null;
}
},
isActive: () => isOpen && session !== null,
};
}
// ── Message Handler ────────────────────────────────────────────
function handleGeminiMessage(ws, message, handlers) {
if (ws.readyState !== 1) return;
const sc = message.serverContent;
// Log non-trivial message keys for debugging transcription issues
const topKeys = Object.keys(message).filter(k => message[k] != null);
const scKeys = sc ? Object.keys(sc).filter(k => sc[k] != null) : [];
if (scKeys.length > 0) {
const interesting = scKeys.filter(k => k !== 'modelTurn' || !sc.modelTurn?.parts?.every(p => p.inlineData));
if (interesting.length > 0 && !(interesting.length === 1 && interesting[0] === 'modelTurn')) {
console.log('[Gemini] serverContent keys:', scKeys.join(', '));
}
}
if (topKeys.some(k => !['serverContent', 'usageMetadata', 'sessionResumptionUpdate'].includes(k))) {
console.log('[Gemini] Top-level message keys:', topKeys.join(', '));
}
// Interrupted — barge-in (process BEFORE audio so the frontend can react immediately)
if (sc?.interrupted) {
safeSend(ws, { type: 'interrupted' });
handlers.onInterrupted?.();
console.log('[Gemini] ⚡ BARGE-IN — AI interrupted the user');
}
// Model content (audio/text parts)
if (sc?.modelTurn?.parts) {
for (const part of sc.modelTurn.parts) {
if (part.inlineData) {
// ── Fast binary path for audio ──
// Instead of wrapping the base64 in JSON, decode it to raw bytes
// and send as a binary WS frame with a 1-byte type header.
// This eliminates JSON.stringify/parse and base64 re-encoding
// overhead on every single audio chunk (saves ~5-15ms per chunk).
try {
const raw = Buffer.from(part.inlineData.data, 'base64');
// Header: 0x01 = audio
const frame = Buffer.allocUnsafe(1 + raw.length);
frame[0] = 0x01;
raw.copy(frame, 1);
if (ws.readyState === 1) {
ws.send(frame);
}
} catch {
// Fallback to JSON if binary send fails
safeSend(ws, {
type: 'audio',
data: part.inlineData.data,
mimeType: part.inlineData.mimeType || 'audio/pcm;rate=24000',
});
}
handlers.onAudioOutput?.();
}
if (part.text) {
safeSend(ws, {
type: 'text',
text: part.text,
partial: !sc.turnComplete,
});
}
}
}
// Turn complete
if (sc?.turnComplete) {
safeSend(ws, { type: 'turn-complete' });
handlers.onTurnComplete?.();
}
// ── Transcription ──────────────────────────────────────────
// Transcription events live inside serverContent in the Gemini Live API protocol.
// Also check top-level as a fallback for potential SDK changes.
const inputTx = sc?.inputTranscription || message.inputTranscription;
const outputTx = sc?.outputTranscription || message.outputTranscription;
if (inputTx) {
const text = inputTx.text;
const finished = !!inputTx.finished;
console.log(`[Gemini] 📝 Input transcription (finished=${finished}): "${(text || '').slice(0, 80)}"`);
if (typeof text === 'string') {
safeSend(ws, {
type: 'input-transcription',
text,
finished,
});
handlers.onInputTranscription?.(text, finished);
}
}
if (outputTx) {
const text = outputTx.text;
const finished = !!outputTx.finished;
console.log(`[Gemini] 📝 Output transcription (finished=${finished}): "${(text || '').slice(0, 80)}"`);
if (typeof text === 'string') {
safeSend(ws, {
type: 'output-transcription',
text,
finished,
});
handlers.onOutputTranscription?.(text, finished);
}
}
// Usage metadata
if (message.usageMetadata) {
safeSend(ws, {
type: 'usage',
metadata: message.usageMetadata,
});
}
// Session resumption handle
if (message.sessionResumptionUpdate) {
console.log('[Gemini] Session resumption handle updated');
}
// Google Search tool call / response (grounding)
if (message.toolCall) {
console.log('[Gemini] 🔍 Tool call received:', JSON.stringify(message.toolCall).slice(0, 200));
}
if (message.toolCallCancellation) {
console.log('[Gemini] Tool call cancelled');
}
}
function safeSend(ws, data) {
if (ws.readyState === 1) {
ws.send(JSON.stringify(data));
}
}
// ── Post-Session Summary Generator ─────────────────────────────
/**
* Generates a structured post-session feedback report using the Gemini text API.
* This is a separate, non-live API call that analyzes the accumulated transcript.
*/
export async function generatePostSessionSummary({ transcript, mode, stats }) {
if (!ai) throw new Error('No Gemini auth configured');
if (!transcript || transcript.length === 0) {
return { summary: 'No transcript data available for analysis.', scores: null };
}
// Build transcript text from array of {role, text} entries
let transcriptText = transcript
.map((t) => `[${t.role.toUpperCase()}]: ${t.text}`)
.join('\n');
// Truncate very long transcripts to avoid hitting token limits
const MAX_TRANSCRIPT_CHARS = 100000;
if (transcriptText.length > MAX_TRANSCRIPT_CHARS) {
const keepChars = Math.floor(MAX_TRANSCRIPT_CHARS * 0.45);
transcriptText =
transcriptText.slice(0, keepChars) +
'\n\n[... middle portion omitted for brevity ...]\n\n' +
transcriptText.slice(-keepChars);
console.log(`[Summary] Transcript truncated from ${transcript.length} entries to fit token limits`);
}
const modeLabel = mode === 'interview' ? 'Behavioral Interview'
: mode === 'presentation' || mode === 'present' ? 'Presentation / Public Speaking'
: 'Startup Pitch';
const prompt = `You are an expert communication coach analyzing a recorded coaching session. The session mode was: ${modeLabel}.
SESSION STATS:
- Duration: ${Math.floor(stats.durationSeconds / 60)}m ${stats.durationSeconds % 60}s
- AI coaching turns: ${stats.turns}
- Barge-in interruptions: ${stats.bargeIns}
- Video frames analyzed: ${stats.imagesSent}
FULL TRANSCRIPT:
${transcriptText}
Produce a structured feedback report in the following JSON format. Be brutally honest but constructive. Every score must be justified by specific moments from the transcript.
{
"overallScore": <number 1-10>,
"headline": "<one-sentence brutal verdict>",
"strengths": ["<specific strength with transcript evidence>", ...],
"weaknesses": ["<specific weakness with transcript evidence>", ...],
"scores": {
"content": { "score": <1-10>, "comment": "<brief>" },
"delivery": { "score": <1-10>, "comment": "<brief>" },
"bodyLanguage": { "score": <1-10>, "comment": "<brief>" },
"structure": { "score": <1-10>, "comment": "<brief>" },
"confidence": { "score": <1-10>, "comment": "<brief>" }
},
"keyMoments": [
{ "timestamp": "<approximate>", "observation": "<what happened>", "verdict": "good|bad|neutral" },
...
],
"improvementPlan": [
"<specific, actionable step 1>",
"<specific, actionable step 2>",
"<specific, actionable step 3>"
],
"nextSessionFocus": "<what to drill next time>"
}
Return ONLY valid JSON, no markdown fences, no explanation.`;
try {
const textModel = process.env.GEMINI_TEXT_MODEL || TEXT_MODEL_NAME;
console.log(`[Summary] Generating post-session report with model: ${textModel}`);
const response = await ai.models.generateContent({
model: textModel,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
});
const text = response.text?.trim() || response.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
if (!text) {
console.error('[Summary] Empty response from Gemini text API');
return { summary: 'Failed to generate summary — empty response.', scores: null };
}
// Parse JSON (strip markdown fences if present despite instruction)
const cleaned = text.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '').trim();
const parsed = JSON.parse(cleaned);
console.log(`[Summary] Report generated — overall score: ${parsed.overallScore}/10`);
return { summary: parsed, scores: parsed.scores };
} catch (err) {
console.error('[Summary] Failed to generate report:', err.message);
return { summary: `Summary generation failed: ${err.message}`, scores: null };
}
}