154 lines
4.9 KiB
TypeScript
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'))
|
|
}
|