fix(geminiLive): revert to raw WebSocket (SDK close without reason)

- Replace @google/genai SDK with raw 'ws' WebSocket
- Setup frame minimal (model + responseModalities AUDIO only)
- Forward client {type:audio} → realtimeInput JSON to Gemini
- Forward Gemini messages verbatim to client
- Detailed [T2] logs for Render debug
- Tests adapted to mock raw WS via clientFactory
This commit is contained in:
Hermann_Kitio 2026-04-27 03:05:12 +03:00
parent 61be6b1959
commit 9da733d156
2 changed files with 360 additions and 349 deletions

View file

@ -1,53 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
// ─── Mock du SDK @google/genai ───────────────────────────────────────────────
//
// On capture les callbacks passés à `ai.live.connect` pour pouvoir simuler les
// événements (onopen, onmessage, onerror, onclose) depuis les tests. La
// fabrique `clientFactory` injectée dans openGeminiLiveSession permet de
// remplacer `new GoogleGenAI(...)` par un stub.
interface CapturedConnect {
model: string;
config: Record<string, unknown>;
callbacks: {
onopen?: () => void;
onmessage?: (msg: unknown) => void;
onerror?: (err: unknown) => void;
onclose?: (evt: unknown) => void;
};
session: {
sendRealtimeInput: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
}
let capturedConnect: CapturedConnect | null = null;
function makeFakeClient() {
return {
live: {
connect: vi.fn(async (params: CapturedConnect) => {
const session = {
sendRealtimeInput: vi.fn(),
close: vi.fn(),
};
capturedConnect = {
model: params.model,
config: params.config,
callbacks: params.callbacks,
session,
};
return session;
}),
},
};
}
import { import {
openGeminiLiveSession, openGeminiLiveSession,
buildT2SystemPrompt, buildT2SystemPrompt,
GEMINI_LIVE_MODEL,
type WebSocketLike, type WebSocketLike,
} from "../geminiLive"; } from "../geminiLive";
@ -75,33 +31,6 @@ const SUJET_OPTS = {
"Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.", "Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.",
}; };
/** Helper : ouvre une session avec un client mocké et retourne la capture. */
async function openWithMock(
client: FakeWs,
extra: Partial<{
onSessionEnd: (transcript: string) => void | Promise<void>;
timeoutMs: number;
warningMs: number;
}> = {},
) {
capturedConnect = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
openGeminiLiveSession(client, {
...SUJET_OPTS,
apiKey: "test-key",
clientFactory: () => makeFakeClient() as any,
...extra,
});
// Le `await live.connect()` est dans un `.then()` du code prod ; on laisse
// les microtasks se vider avant de retourner la capture.
await Promise.resolve();
await Promise.resolve();
if (!capturedConnect) {
throw new Error("Le mock du SDK n'a pas capturé de connect()");
}
return capturedConnect;
}
describe("buildT2SystemPrompt", () => { describe("buildT2SystemPrompt", () => {
it("substitue role et contexte dans le template", () => { it("substitue role et contexte dans le template", () => {
const prompt = buildT2SystemPrompt(SUJET_OPTS); const prompt = buildT2SystemPrompt(SUJET_OPTS);
@ -114,87 +43,117 @@ describe("buildT2SystemPrompt", () => {
}); });
}); });
describe("openGeminiLiveSession (SDK)", () => { describe("openGeminiLiveSession (raw WS)", () => {
let originalKey: string | undefined;
beforeEach(() => { beforeEach(() => {
originalKey = process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY = "test-key";
vi.useFakeTimers(); vi.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
if (originalKey === undefined) {
delete process.env.GEMINI_API_KEY;
} else {
process.env.GEMINI_API_KEY = originalKey;
}
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
capturedConnect = null;
}); });
it("appelle live.connect avec une config minimale (debug Sprint 6d — isolement champ rejeté)", async () => { it("envoie le setup frame minimal à l'open Gemini (model + responseModalities AUDIO)", () => {
const client = new FakeWs(); const client = new FakeWs();
const capture = await openWithMock(client); const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
expect(capture.model).toMatch(/gemini/); expect(gemini.sent).toHaveLength(1);
const config = capture.config; const setup = JSON.parse(gemini.sent[0] as string);
expect(config.responseModalities).toContain("AUDIO"); expect(setup.setup.model).toBe(`models/${GEMINI_LIVE_MODEL}`);
// ⚠ DEBUG : les autres champs sont temporairement commentés dans expect(setup.setup.generationConfig.responseModalities).toContain("AUDIO");
// geminiLive.ts pour isoler celui qui fait rejeter le setup par Gemini. // ⚠ DEBUG : champs volontairement absents tant que setupComplete n'est pas
expect(config.systemInstruction).toBeUndefined(); // confirmé en prod. Réintégration champ par champ ensuite.
expect(config.inputAudioTranscription).toBeUndefined(); expect(setup.setup.systemInstruction).toBeUndefined();
expect(config.outputAudioTranscription).toBeUndefined(); expect(setup.setup.inputAudioTranscription).toBeUndefined();
expect(config.realtimeInputConfig).toBeUndefined(); expect(setup.setup.outputAudioTranscription).toBeUndefined();
expect(setup.setup.realtimeInputConfig).toBeUndefined();
}); });
it("forwarde un chunk audio client {type:'audio'} via session.sendRealtimeInput (PCM 16k base64)", async () => { it("forwarde un chunk audio client {type:'audio'} en realtimeInput vers Gemini", () => {
const client = new FakeWs(); const client = new FakeWs();
const capture = await openWithMock(client); const gemini = new FakeWs();
capture.callbacks.onopen?.(); openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
const base64 = "AQIDBA=="; // base64 de [1,2,3,4] const base64 = "AQIDBA==";
client.emit("message", JSON.stringify({ type: "audio", data: base64 })); client.emit("message", JSON.stringify({ type: "audio", data: base64 }));
expect(capture.session.sendRealtimeInput).toHaveBeenCalledTimes(1); // [0] = setup frame, [1] = realtimeInput audio
expect(capture.session.sendRealtimeInput).toHaveBeenCalledWith({ expect(gemini.sent).toHaveLength(2);
audio: { data: base64, mimeType: "audio/pcm;rate=16000" }, const audioFrame = JSON.parse(gemini.sent[1] as string);
expect(audioFrame).toEqual({
realtimeInput: {
audio: { data: base64, mimeType: "audio/pcm;rate=16000" },
},
}); });
}); });
it("forwarde un message Gemini (audio inlineData) au client en JSON", async () => { it("forwarde un message Gemini (Buffer audio inlineData) verbatim au client", () => {
const client = new FakeWs(); const client = new FakeWs();
const capture = await openWithMock(client); const gemini = new FakeWs();
capture.callbacks.onopen?.(); openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
const geminiMsg = { const buf = Buffer.from([0x10, 0x20, 0x30]);
serverContent: { gemini.emit("message", buf);
modelTurn: {
parts: [
{
inlineData: { data: "EAYE", mimeType: "audio/pcm;rate=24000" },
},
],
},
},
};
capture.callbacks.onmessage?.(geminiMsg);
expect(client.sent).toHaveLength(1); expect(client.sent).toHaveLength(1);
expect(JSON.parse(client.sent[0] as string)).toEqual(geminiMsg); expect(client.sent[0]).toBe(buf);
}); });
it("accumule input/outputTranscription et reconstruit le transcript chronologique", async () => { it("accumule input/outputTranscription depuis les messages JSON Gemini", async () => {
const client = new FakeWs(); const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
const capture = await openWithMock(client, { onSessionEnd }); openGeminiLiveSession(client, {
capture.callbacks.onopen?.(); ...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
capture.callbacks.onmessage?.({ gemini.emit(
serverContent: { "message",
inputTranscription: { text: "Bonjour, je voudrais louer." }, JSON.stringify({
}, serverContent: {
}); inputTranscription: { text: "Bonjour, je voudrais louer." },
capture.callbacks.onmessage?.({ },
serverContent: { }),
outputTranscription: { text: "Bonjour, cest pour quel quartier ?" }, );
}, gemini.emit(
}); "message",
capture.callbacks.onmessage?.({ JSON.stringify({
serverContent: { inputTranscription: { text: "Le centre-ville." } }, serverContent: {
}); outputTranscription: { text: "Bonjour, cest pour quel quartier ?" },
},
}),
);
gemini.emit(
"message",
JSON.stringify({
serverContent: { inputTranscription: { text: "Le centre-ville." } },
}),
);
client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@ -205,24 +164,35 @@ describe("openGeminiLiveSession (SDK)", () => {
); );
}); });
it("ferme la session SDK après onSessionEnd, sans fermer le client", async () => { it("ferme Gemini après onSessionEnd, sans fermer le client", async () => {
const client = new FakeWs(); const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
const capture = await openWithMock(client, { onSessionEnd }); openGeminiLiveSession(client, {
capture.callbacks.onopen?.(); ...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
expect(capture.session.close).toHaveBeenCalledTimes(1); expect(gemini.closed).toBe(true);
expect(gemini.closeCode).toBe(1000);
expect(client.closed).toBe(false); expect(client.closed).toBe(false);
}); });
it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => { it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => {
const client = new FakeWs(); const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
const capture = await openWithMock(client, { onSessionEnd }); openGeminiLiveSession(client, {
capture.callbacks.onopen?.(); ...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
await vi.advanceTimersByTimeAsync(180_000); await vi.advanceTimersByTimeAsync(180_000);
const warningFrame = client.sent.find( const warningFrame = client.sent.find(
@ -237,14 +207,19 @@ describe("openGeminiLiveSession (SDK)", () => {
await vi.advanceTimersByTimeAsync(30_000); await vi.advanceTimersByTimeAsync(30_000);
expect(onSessionEnd).toHaveBeenCalledTimes(1); expect(onSessionEnd).toHaveBeenCalledTimes(1);
expect(capture.session.close).toHaveBeenCalled(); expect(gemini.closed).toBe(true);
}); });
it("signal end client est idempotent (un seul onSessionEnd)", async () => { it("signal end client est idempotent (un seul onSessionEnd)", async () => {
const client = new FakeWs(); const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
const capture = await openWithMock(client, { onSessionEnd }); openGeminiLiveSession(client, {
capture.callbacks.onopen?.(); ...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" }));
client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" }));
@ -253,47 +228,45 @@ describe("openGeminiLiveSession (SDK)", () => {
expect(onSessionEnd).toHaveBeenCalledTimes(1); expect(onSessionEnd).toHaveBeenCalledTimes(1);
}); });
it("onclose SDK avant fin → close client 4006 GEMINI_DISCONNECTED", async () => { it("close Gemini avant fin → close client 4006 GEMINI_DISCONNECTED", () => {
const client = new FakeWs(); const client = new FakeWs();
const capture = await openWithMock(client); const gemini = new FakeWs();
capture.callbacks.onopen?.(); openGeminiLiveSession(client, {
...SUJET_OPTS,
capture.callbacks.onclose?.({ code: 1000 }); clientFactory: () => gemini,
});
gemini.emit("open");
gemini.emit("close", 1006, Buffer.from(""));
expect(client.closed).toBe(true); expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4006); expect(client.closeCode).toBe(4006);
expect(client.closeReason).toBe("GEMINI_DISCONNECTED"); expect(client.closeReason).toBe("GEMINI_DISCONNECTED");
}); });
it("onerror SDK → close client 4006", async () => { it("error Gemini → close client 4006", () => {
const client = new FakeWs(); const client = new FakeWs();
const capture = await openWithMock(client); const gemini = new FakeWs();
capture.callbacks.onopen?.(); openGeminiLiveSession(client, {
...SUJET_OPTS,
capture.callbacks.onerror?.(new Error("boom")); clientFactory: () => gemini,
});
gemini.emit("open");
gemini.emit("error", new Error("boom"));
expect(client.closed).toBe(true); expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4006); expect(client.closeCode).toBe(4006);
}); });
it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à live.connect", () => { it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à la factory", () => {
const originalKey = process.env.GEMINI_API_KEY;
delete process.env.GEMINI_API_KEY; delete process.env.GEMINI_API_KEY;
capturedConnect = null;
const client = new FakeWs(); const client = new FakeWs();
const factory = vi.fn(() => makeFakeClient()); const factory = vi.fn(() => new FakeWs());
openGeminiLiveSession(client, { openGeminiLiveSession(client, { ...SUJET_OPTS, clientFactory: factory });
...SUJET_OPTS,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
clientFactory: factory as any,
});
expect(factory).not.toHaveBeenCalled(); expect(factory).not.toHaveBeenCalled();
expect(client.closed).toBe(true); expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4005); expect(client.closeCode).toBe(4005);
expect(client.closeReason).toBe("GEMINI_CONFIG"); expect(client.closeReason).toBe("GEMINI_CONFIG");
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
}); });
}); });

View file

@ -1,36 +1,28 @@
/** /**
* geminiLive.ts Sprint 6d. * geminiLive.ts Sprint 6d (revert WS brut).
* *
* Migration du WebSocket brut (`wss://generativelanguage.googleapis.com/...`) * Le SDK `@google/genai` fermait la session sans setupComplete ni raison
* vers le SDK officiel `@google/genai` v1.50.x. Motif : Google a migré les * exploitable. On revient au WebSocket brut (package `ws`) qui était utilisé
* clés API vers le mode "Vertex AI Express", incompatible avec l'endpoint WS * par `test-gemini-live.js` et permet de loguer précisément ce que Gemini
* historique (réponse 403 systématique). Le SDK gère l'auth automatiquement * répond. Config setup réduite au strict minimum tant que `setupComplete`
* et accepte les clés Express bound à un service account. * n'est pas confirmé en prod ; on réintègre champs un par un ensuite.
* *
* Interface publique (consommée par `routes/t2live.ts`) : * Interface publique (consommée par `routes/t2live.ts`) INCHANGÉE :
* - openGeminiLiveSession(clientWs, opts) : ouvre une session Live et * - openGeminiLiveSession(clientWs, opts)
* proxifie les messages dans les deux sens entre le client (navigateur) * - WebSocketLike, OpenGeminiLiveSessionOptions
* et Gemini, accumule les transcripts, gère timeouts + close codes. * - buildT2SystemPrompt({role, contexte})
* - WebSocketLike : interface minimale pour le client WS (Hono adapter). * - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS
* - buildT2SystemPrompt({role, contexte}) : prompt dynamique T2 Live.
* - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS.
*
* Cf. docs/IMPLEMENTATION_T2_LIVE.md §3, docs/Prompt_t2live.md §3.
*/ */
import { import { WebSocket as NodeWebSocket } from "ws";
GoogleGenAI,
Modality, export const GEMINI_LIVE_URL =
StartSensitivity, "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
EndSensitivity,
type Session,
} from "@google/genai";
/** /**
* Modèle Live cible. `gemini-3.1-flash-live-preview` est le choix par défaut * Modèle Live cible. `gemini-2.0-flash-live-001` est le modèle Live confirmé
* (Sprint 6d), à valider sur Express Mode via `test-gemini-live.js`. Fallback * par la doc Google pour les clés API Developer + Express. Format `models/...`
* documenté : `gemini-2.0-flash-live-001` (modèle Live garanti sur Express * dans le setup frame natif (cf. `test-gemini-live.js`).
* d'après la doc Vertex Express).
*/ */
export const GEMINI_LIVE_MODEL = "gemini-2.0-flash-live-001"; export const GEMINI_LIVE_MODEL = "gemini-2.0-flash-live-001";
@ -41,7 +33,8 @@ export const T2_SESSION_WARNING_MS = 180_000;
/** /**
* Construit le prompt système T2 Live à partir du sujet (role + contexte). * Construit le prompt système T2 Live à partir du sujet (role + contexte).
* Cf. docs/Prompt_t2live.md §3. * Cf. docs/Prompt_t2live.md §3. Conservé en signature pour usage futur quand
* `systemInstruction` sera réintégré dans le setup frame.
*/ */
export function buildT2SystemPrompt(input: { export function buildT2SystemPrompt(input: {
role: string; role: string;
@ -65,6 +58,7 @@ Règles à respecter impérativement :
/** /**
* Subset minimal d'une WebSocket compatible avec : * Subset minimal d'une WebSocket compatible avec :
* - le wrapper exposé par @hono/node-ws (côté client navigateur) * - 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 * - les fakes basés sur EventEmitter dans les tests
*/ */
export interface WebSocketLike { export interface WebSocketLike {
@ -90,17 +84,16 @@ export interface OpenGeminiLiveSessionOptions {
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */ /** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
apiKey?: string; apiKey?: string;
/** /**
* Injection pour les tests fabrique de client SDK. Permet de remplacer * Injection pour les tests fabrique de WebSocket vers Gemini.
* `new GoogleGenAI(...)` par un mock dans les tests sans toucher au code prod.
*/ */
clientFactory?: (apiKey: string) => GoogleGenAI; clientFactory?: (url: string) => WebSocketLike;
} }
/** /**
* Forme minimale d'un message Live retourné par le SDK. On n'exporte pas * Forme minimale d'un message Gemini Live JSON entrant.
* `LiveServerMessage` du SDK pour ne pas coupler les tests à son shape exact.
*/ */
interface LiveServerMessage { interface GeminiServerMessage {
setupComplete?: unknown;
serverContent?: { serverContent?: {
modelTurn?: { modelTurn?: {
parts?: Array<{ parts?: Array<{
@ -112,7 +105,6 @@ interface LiveServerMessage {
interrupted?: boolean; interrupted?: boolean;
turnComplete?: boolean; turnComplete?: boolean;
}; };
setupComplete?: unknown;
} }
interface TranscriptEntry { interface TranscriptEntry {
@ -185,24 +177,70 @@ function parseAudioChunk(data: unknown): string | null {
} }
/** /**
* Ouvre une session Gemini Live via le SDK et proxifie les messages * Tente de parser un message Gemini en JSON. Retourne null si binaire / non-JSON.
* dans les deux sens entre le client (navigateur) et Gemini. */
function tryParseGeminiJson(data: unknown): GeminiServerMessage | null {
let text: string;
if (typeof data === "string") {
text = data;
} else if (data instanceof Buffer) {
try {
text = data.toString("utf8");
if (!text.startsWith("{")) return null;
} catch {
return null;
}
} else if (typeof data === "object" && data !== null && "toString" in data) {
try {
text = (data as { toString: () => string }).toString();
if (!text.startsWith("{")) return null;
} catch {
return null;
}
} else {
return null;
}
try {
return JSON.parse(text) as GeminiServerMessage;
} catch {
return null;
}
}
/**
* Construit le setup frame minimal Gemini Live (équivalent du mode
* `minimal` de `test-gemini-live.js`). Les champs `systemInstruction`,
* `inputAudioTranscription`, `outputAudioTranscription`,
* `realtimeInputConfig.automaticActivityDetection` sont volontairement
* retirés tant que `setupComplete` n'est pas confirmé en prod.
*/
function buildSetupFrame(): string {
return JSON.stringify({
setup: {
model: `models/${GEMINI_LIVE_MODEL}`,
generationConfig: {
responseModalities: ["AUDIO"],
},
},
});
}
/**
* Ouvre une session Gemini Live via WebSocket brut (`ws://...?key=...`) et
* proxifie les messages dans les deux sens entre le client (navigateur) et
* Gemini.
* *
* - Init : `new GoogleGenAI({ vertexai: true, apiKey })` mode Vertex Express * - URL : GEMINI_LIVE_URL?key=apiKey
* (compatible avec les clés API auto-bound à un service account). * - À l'open Gemini : envoi du setup frame minimal.
* - Setup config : modèle + responseModalities AUDIO + systemInstruction
* + inputAudioTranscription + outputAudioTranscription + VAD.
* - Forward client Gemini : parse `{type:'audio', data: base64}` * - Forward client Gemini : parse `{type:'audio', data: base64}`
* `session.sendRealtimeInput({audio: {data, mimeType: 'audio/pcm;rate=16000'}})`. * message JSON `{ realtimeInput: { audio: { data, mimeType } } }`.
* - Forward Gemini client : `clientWs.send(JSON.stringify(msg))` (le frontend * - Forward Gemini client : forward verbatim (string ou Buffer).
* parse `serverContent.modelTurn.parts[].inlineData.data`).
* - Accumule input/outputTranscription pour la correction finale. * - Accumule input/outputTranscription pour la correction finale.
* - Détecte `{type:'end'}` du client fin de session. * - Détecte `{type:'end'}` du client fin de session.
* - Timer 210 s : warning à 180 s, fin auto à 210 s. * - Timer 210 s : warning à 180 s, fin auto à 210 s.
* - En fin : `onSessionEnd(transcript)` puis ferme la session SDK. Le client WS * - En fin : `onSessionEnd(transcript)` puis ferme Gemini. Le client WS
* n'est PAS fermé ici — c'est l'appelant qui décide (envoi du rapport puis * n'est PAS fermé ici — c'est l'appelant qui décide.
* close 1000). * - Erreur Gemini / close prématurée close client 4006 GEMINI_DISCONNECTED.
* - Erreur SDK / close Gemini close client 4006 GEMINI_DISCONNECTED.
* - GEMINI_API_KEY absente close client 4005 GEMINI_CONFIG. * - GEMINI_API_KEY absente close client 4005 GEMINI_CONFIG.
*/ */
export function openGeminiLiveSession( export function openGeminiLiveSession(
@ -218,18 +256,27 @@ export function openGeminiLiveSession(
const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS; const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS;
const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS; const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS;
const systemPrompt = buildT2SystemPrompt({ // Conservé en signature pour usage futur (réintégration `systemInstruction`).
const _systemPrompt = buildT2SystemPrompt({
role: opts.role, role: opts.role,
contexte: opts.contexte, contexte: opts.contexte,
}); });
void _systemPrompt;
const ai = opts.clientFactory?.(apiKey) ?? new GoogleGenAI({ apiKey }); const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const factory =
opts.clientFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***");
console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL);
const geminiWs = factory(url);
const transcriptEntries: TranscriptEntry[] = []; const transcriptEntries: TranscriptEntry[] = [];
let sessionEnded = false; let sessionEnded = false;
let warningTimer: ReturnType<typeof setTimeout> | null = null; let warningTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null; let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
let session: Session | null = null;
const clearTimers = () => { const clearTimers = () => {
if (warningTimer !== null) { if (warningTimer !== null) {
@ -246,12 +293,10 @@ export function openGeminiLiveSession(
if (sessionEnded) return; if (sessionEnded) return;
sessionEnded = true; sessionEnded = true;
clearTimers(); clearTimers();
if (session) { try {
try { geminiWs.close(1000);
session.close(); } catch {
} catch { /* ignore */
/* ignore */
}
} }
if (opts.onSessionEnd) { if (opts.onSessionEnd) {
try { try {
@ -265,130 +310,123 @@ export function openGeminiLiveSession(
} }
}; };
const handleSdkMessage = (msg: LiveServerMessage) => { geminiWs.on("open", () => {
// Accumuler transcripts pour la correction finale. console.log("[T2] Gemini WS open");
const sc = msg.serverContent; const frame = buildSetupFrame();
if (sc?.inputTranscription?.text && sc.inputTranscription.text.length > 0) { console.log("[T2] Gemini setup frame:", frame);
transcriptEntries.push({
speaker: "candidat",
text: sc.inputTranscription.text,
});
}
if (
sc?.outputTranscription?.text &&
sc.outputTranscription.text.length > 0
) {
transcriptEntries.push({
speaker: "examinateur",
text: sc.outputTranscription.text,
});
}
// Forward verbatim au client. Le frontend parse serverContent.modelTurn.
try { try {
clientWs.send(JSON.stringify(msg)); geminiWs.send(frame);
} catch { } catch (err) {
void endSession(); console.error(
} "[T2] Gemini setup frame send failed:",
}; err instanceof Error ? err.message : String(err),
);
// ── Ouverture de la session SDK ──────────────────────────────────────
// ⚠ DEBUG : config minimale pour isoler le champ qui fait rejeter le setup
// par Gemini. À restaurer une fois identifié.
// Variables conservées en signature pour ne pas casser les imports / la
// construction du prompt qui valide le sujet.
void systemPrompt;
void StartSensitivity;
void EndSensitivity;
const sdkConfig = {
responseModalities: [Modality.AUDIO],
// systemInstruction: systemPrompt,
// inputAudioTranscription: {},
// outputAudioTranscription: {},
// realtimeInputConfig: {
// automaticActivityDetection: {
// disabled: false,
// startOfSpeechSensitivity: StartSensitivity.START_SENSITIVITY_LOW,
// endOfSpeechSensitivity: EndSensitivity.END_SENSITIVITY_LOW,
// silenceDurationMs: 2000,
// },
// },
};
console.log("[T2] SDK config:", JSON.stringify(sdkConfig, null, 2));
console.log("[T2] SDK model:", GEMINI_LIVE_MODEL);
ai.live
.connect({
model: GEMINI_LIVE_MODEL,
config: sdkConfig,
callbacks: {
onopen: () => {
console.log("[T2] Gemini SDK onopen");
// Démarrer les timers une fois la session effectivement ouverte.
warningTimer = setTimeout(() => {
if (sessionEnded) return;
try {
clientWs.send(
JSON.stringify({
type: "warning",
message: "30 secondes restantes",
}),
);
} catch {
/* ignore */
}
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
},
onmessage: (msg: LiveServerMessage) => {
console.log(
"[T2] Gemini SDK message:",
JSON.stringify(msg).substring(0, 200),
);
handleSdkMessage(msg);
},
onerror: (err: unknown) => {
console.log("[T2] Gemini SDK error:", JSON.stringify(err));
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
},
onclose: (e: unknown) => {
console.log("[T2] Gemini SDK close:", JSON.stringify(e));
if (!sessionEnded) {
clearTimers();
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
},
},
})
.then((s: Session) => {
session = s;
})
.catch((err: unknown) => {
console.error("[T2] SDK connect error:", err);
sessionEnded = true;
clearTimers();
try { try {
clientWs.close(4006, "GEMINI_DISCONNECTED"); clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch { } catch {
/* ignore */ /* ignore */
} }
}); return;
}
// Timers démarrés à l'ouverture de la WS (avant setupComplete éventuel).
warningTimer = setTimeout(() => {
if (sessionEnded) return;
try {
clientWs.send(
JSON.stringify({
type: "warning",
message: "30 secondes restantes",
}),
);
} catch {
/* ignore */
}
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
});
geminiWs.on("message", (data) => {
const preview =
typeof data === "string"
? data.slice(0, 300)
: data instanceof Buffer
? data.toString("utf8").slice(0, 300)
: "[binary]";
console.log("[T2] Gemini WS message:", preview);
// Accumuler input/outputTranscription.
const parsed = tryParseGeminiJson(data);
if (parsed) {
const sc = parsed.serverContent;
if (
sc?.inputTranscription?.text &&
sc.inputTranscription.text.length > 0
) {
transcriptEntries.push({
speaker: "candidat",
text: sc.inputTranscription.text,
});
}
if (
sc?.outputTranscription?.text &&
sc.outputTranscription.text.length > 0
) {
transcriptEntries.push({
speaker: "examinateur",
text: sc.outputTranscription.text,
});
}
}
// Forward verbatim au client (string ou Buffer audio inlineData).
try {
clientWs.send(data);
} catch {
void endSession();
}
});
geminiWs.on("close", (code, reason) => {
const reasonStr =
reason instanceof Buffer
? reason.toString("utf8")
: typeof reason === "string"
? reason
: "";
console.log(
"[T2] Gemini WS close:",
JSON.stringify({ code, reason: reasonStr }),
);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
geminiWs.on("error", (err) => {
console.log(
"[T2] Gemini WS error:",
JSON.stringify(err instanceof Error ? { message: err.message } : err),
);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
// ── Forward client → Gemini ────────────────────────────────────────── // ── Forward client → Gemini ──────────────────────────────────────────
clientWs.on("message", (data) => { clientWs.on("message", (data) => {
@ -397,46 +435,46 @@ export function openGeminiLiveSession(
return; return;
} }
const audioBase64 = parseAudioChunk(data); const audioBase64 = parseAudioChunk(data);
if (audioBase64 !== null && session !== null && !sessionEnded) { if (audioBase64 !== null && !sessionEnded) {
try { try {
session.sendRealtimeInput({ geminiWs.send(
audio: { JSON.stringify({
data: audioBase64, realtimeInput: {
mimeType: "audio/pcm;rate=16000", audio: {
}, data: audioBase64,
}); mimeType: "audio/pcm;rate=16000",
},
},
}),
);
} catch (err) { } catch (err) {
console.log( console.log(
"[T2] sendRealtimeInput a échoué :", "[T2] Gemini WS send (audio) failed:",
err instanceof Error ? err.message : String(err), err instanceof Error ? err.message : String(err),
); );
void endSession(); void endSession();
} }
} }
// Tout autre message client est ignoré (ex: ping keep-alive frontend). // Tout autre message client est ignoré.
}); });
clientWs.on("close", () => { clientWs.on("close", () => {
clearTimers(); clearTimers();
sessionEnded = true; sessionEnded = true;
if (session) { try {
try { geminiWs.close(1000);
session.close(); } catch {
} catch { /* ignore */
/* ignore */
}
} }
}); });
clientWs.on("error", () => { clientWs.on("error", () => {
clearTimers(); clearTimers();
sessionEnded = true; sessionEnded = true;
if (session) { try {
try { geminiWs.close(1011);
session.close(); } catch {
} catch { /* ignore */
/* ignore */
}
} }
}); });
} }