expria-backend/src/lib/geminiLive.ts

154 lines
4.9 KiB
TypeScript

import { WebSocket as NodeWebSocket } from 'ws'
export const T2_SYSTEM_PROMPT = `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif).
RÔLE : Tu incarnes agent immobilier.
CONTEXTE : Le candidat cherche un appartement à louer.
RÈGLES ABSOLUES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1.
2. Tu NE corriges JAMAIS les erreurs du candidat.
3. Tu attends que le candidat finisse sa question avant de répondre.
4. Tes réponses sont courtes (15 à 25 mots maximum).
5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises.
6. Si le candidat est vague, réponds de façon évasive pour le pousser à reformuler.
7. Si le candidat reste silencieux, attends. Ne pose JAMAIS de question spontanée après tes réponses. C'est au candidat d'agir.
8. En dernier recours uniquement (silence prolongé) : "Vous avez d'autres questions ?"
9. Ne prends jamais d'initiatives : réponds uniquement aux questions posées.
10. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste.
11. JAMAIS de listes ni de structure numérotée dans tes réponses.
12. Ne mentionne jamais que tu es une IA.
Commence l'exercice en te présentant brièvement dans ton rôle (1 phrase courte),
puis attends que le candidat prenne l'initiative.`
export const GEMINI_LIVE_URL =
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
export const GEMINI_LIVE_MODEL = 'models/gemini-2.5-flash-native-audio-latest'
/**
* Subset minimal d'une WebSocket — compatible avec :
* - le wrapper exposé par @hono/node-ws (côté client navigateur)
* - la WebSocket de `ws` (côté Gemini)
* - les fakes basés sur EventEmitter dans les tests
*/
export interface WebSocketLike {
send(data: unknown): void
close(code?: number, reason?: string): void
on(event: 'message', listener: (data: unknown) => void): void
on(event: 'close', listener: (code?: number, reason?: unknown) => void): void
on(event: 'error', listener: (err: unknown) => void): void
on(event: 'open', listener: () => void): void
}
export interface OpenGeminiLiveSessionOptions {
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
geminiFactory?: (url: string) => WebSocketLike
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
apiKey?: string
}
function buildSetupFrame(): string {
return JSON.stringify({
setup: {
model: GEMINI_LIVE_MODEL,
systemInstruction: {
parts: [{ text: T2_SYSTEM_PROMPT }],
},
generationConfig: {
responseModalities: ['AUDIO'],
},
},
})
}
/**
* Ouvre une session Gemini Live et proxifie les messages
* dans les deux sens entre le client (navigateur) et Gemini.
*
* - À l'open Gemini : envoie le setup frame (modèle + system_instruction).
* - Forward transparent des frames audio dans les deux directions.
* - Fermeture coordonnée : close d'un côté → close de l'autre.
* - Erreur Gemini → close client avec code 1011.
* - Si GEMINI_API_KEY est absente : close client immédiat avec 1011.
*/
export function openGeminiLiveSession(
clientWs: WebSocketLike,
opts: OpenGeminiLiveSessionOptions = {}
): void {
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY
if (!apiKey) {
clientWs.close(1011, 'CONFIG_ERROR')
return
}
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`
const factory =
opts.geminiFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike)
const geminiWs = factory(url)
let closed = false
const closeBoth = (code = 1000, reason = '') => {
if (closed) return
closed = true
try {
clientWs.close(code, reason)
} catch {
/* ignore */
}
try {
geminiWs.close(code, reason)
} catch {
/* ignore */
}
}
geminiWs.on('open', () => {
console.log('[T2] Gemini WS opened')
try {
geminiWs.send(buildSetupFrame())
console.log('[T2] Setup frame sent')
} catch {
closeBoth(1011, 'SETUP_FAILED')
}
})
geminiWs.on('message', (data) => {
console.log(
'[T2] Gemini message received, type:',
typeof data,
'content:',
(data as { toString?: () => string })?.toString?.().slice(0, 500)
)
try {
clientWs.send(data)
} catch {
closeBoth(1011, 'CLIENT_SEND_FAILED')
}
})
clientWs.on('message', (data) => {
try {
geminiWs.send(data)
} catch {
closeBoth(1011, 'GEMINI_SEND_FAILED')
}
})
geminiWs.on('close', (code, reason) => {
console.log('[T2] Gemini closed, code:', code, 'reason:', reason)
closeBoth(1000)
})
clientWs.on('close', () => closeBoth(1000))
geminiWs.on('error', (err) => {
console.log('[T2] Gemini error:', (err as Error)?.message)
closeBoth(1011, 'GEMINI_ERROR')
})
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
}