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:
parent
61be6b1959
commit
9da733d156
2 changed files with 360 additions and 349 deletions
|
|
@ -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, c’est pour quel quartier ?" },
|
);
|
||||||
},
|
gemini.emit(
|
||||||
});
|
"message",
|
||||||
capture.callbacks.onmessage?.({
|
JSON.stringify({
|
||||||
serverContent: { inputTranscription: { text: "Le centre-ville." } },
|
serverContent: {
|
||||||
});
|
outputTranscription: { text: "Bonjour, c’est 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;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue