feat: WS /t2/live — proxy Gemini Live API — 124/124 tests
This commit is contained in:
parent
f08be960b0
commit
653fc3150e
8 changed files with 422 additions and 66 deletions
140
src/lib/geminiLive.ts
Normal file
140
src/lib/geminiLive.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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.0-flash-exp'
|
||||
|
||||
/**
|
||||
* 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: () => 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,
|
||||
system_instruction: {
|
||||
parts: [{ text: T2_SYSTEM_PROMPT }],
|
||||
},
|
||||
generation_config: {
|
||||
response_modalities: ['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', () => {
|
||||
try {
|
||||
geminiWs.send(buildSetupFrame())
|
||||
} catch {
|
||||
closeBoth(1011, 'SETUP_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
geminiWs.on('message', (data) => {
|
||||
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', () => closeBoth(1000))
|
||||
clientWs.on('close', () => closeBoth(1000))
|
||||
|
||||
geminiWs.on('error', () => closeBoth(1011, 'GEMINI_ERROR'))
|
||||
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue