236 lines
7.4 KiB
TypeScript
236 lines
7.4 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import type { RawData } from "npm:@types/ws";
|
|
import { WebSocket } from "npm:ws";
|
|
import { addConversation, getDeviceInfo } from "../supabase.ts";
|
|
import { createOpusPacketizer, isDev, xaiApiKey, defaultGrokVoice } from "../utils.ts";
|
|
|
|
const XAI_REALTIME_URL = "wss://api.x.ai/v1/realtime";
|
|
|
|
export const connectToGrok = async ({
|
|
ws,
|
|
payload,
|
|
connectionPcmFile,
|
|
firstMessage,
|
|
systemPrompt,
|
|
closeHandler,
|
|
}: ProviderArgs) => {
|
|
const { user, supabase } = payload;
|
|
|
|
if (!xaiApiKey) {
|
|
throw new Error("XAI_API_KEY is not set");
|
|
}
|
|
|
|
const voice = user.personality?.oai_voice ?? defaultGrokVoice;
|
|
|
|
const opus = createOpusPacketizer((packet) => ws.send(packet));
|
|
|
|
const grokWs = new WebSocket(XAI_REALTIME_URL, {
|
|
headers: {
|
|
Authorization: `Bearer ${xaiApiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
let isConnected = false;
|
|
const messageQueue: RawData[] = [];
|
|
|
|
let createdSent = false;
|
|
let outputTranscript = "";
|
|
|
|
const sendResponseCreated = async () => {
|
|
try {
|
|
const device = await getDeviceInfo(supabase, user.user_id);
|
|
opus.reset();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "server",
|
|
msg: "RESPONSE.CREATED",
|
|
volume_control: device?.volume ?? 100,
|
|
}),
|
|
);
|
|
} catch {
|
|
ws.send(JSON.stringify({ type: "server", msg: "RESPONSE.CREATED" }));
|
|
}
|
|
};
|
|
|
|
const sendFirstMessage = () => {
|
|
if (!firstMessage) return;
|
|
grokWs.send(
|
|
JSON.stringify({
|
|
type: "conversation.item.create",
|
|
item: {
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: firstMessage }],
|
|
},
|
|
}),
|
|
);
|
|
grokWs.send(JSON.stringify({ type: "response.create" }));
|
|
};
|
|
|
|
grokWs.on("open", () => {
|
|
isConnected = true;
|
|
|
|
grokWs.send(
|
|
JSON.stringify({
|
|
type: "session.update",
|
|
session: {
|
|
voice,
|
|
instructions: systemPrompt,
|
|
turn_detection: { type: "server_vad" },
|
|
audio: {
|
|
input: { format: { type: "audio/pcm", rate: 16000 } },
|
|
output: { format: { type: "audio/pcm", rate: 24000 } },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
sendFirstMessage();
|
|
|
|
while (messageQueue.length > 0) {
|
|
const queuedMessage = messageQueue.shift();
|
|
if (queuedMessage) {
|
|
messageHandler(queuedMessage, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
grokWs.on("message", async (data: Buffer) => {
|
|
let event: any;
|
|
try {
|
|
event = JSON.parse(data.toString("utf-8"));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case "response.created":
|
|
if (!createdSent) {
|
|
await sendResponseCreated();
|
|
createdSent = true;
|
|
}
|
|
break;
|
|
|
|
case "response.output_audio_transcript.delta":
|
|
if (typeof event.delta === "string") {
|
|
outputTranscript += event.delta;
|
|
}
|
|
break;
|
|
|
|
case "response.output_audio.delta":
|
|
if (typeof event.delta === "string") {
|
|
const pcmChunk = Buffer.from(event.delta, "base64");
|
|
// Use Opus packetizer to encode and send audio
|
|
opus.push(pcmChunk);
|
|
}
|
|
break;
|
|
|
|
case "conversation.item.input_audio_transcription.completed":
|
|
if (typeof event.transcript === "string" && event.transcript.length > 0) {
|
|
await addConversation(supabase, "user", event.transcript, user);
|
|
}
|
|
break;
|
|
|
|
case "input_audio_buffer.committed":
|
|
ws.send(JSON.stringify({ type: "server", msg: "AUDIO.COMMITTED" }));
|
|
break;
|
|
|
|
case "response.done":
|
|
// Flush any remaining audio
|
|
opus.flush(true);
|
|
|
|
if (outputTranscript) {
|
|
await addConversation(supabase, "assistant", outputTranscript, user);
|
|
outputTranscript = "";
|
|
}
|
|
ws.send(JSON.stringify({ type: "server", msg: "RESPONSE.COMPLETE" }));
|
|
createdSent = false;
|
|
break;
|
|
|
|
case "error":
|
|
ws.send(JSON.stringify({ type: "server", msg: "RESPONSE.ERROR" }));
|
|
createdSent = false;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.error("Error processing Grok event:", err);
|
|
ws.send(JSON.stringify({ type: "server", msg: "RESPONSE.ERROR" }));
|
|
createdSent = false;
|
|
}
|
|
});
|
|
|
|
grokWs.on("close", () => {
|
|
ws.close();
|
|
});
|
|
|
|
grokWs.on("error", (error: any) => {
|
|
console.error("Grok WebSocket error:", error);
|
|
ws.send(JSON.stringify({ type: "server", msg: "RESPONSE.ERROR" }));
|
|
});
|
|
|
|
const messageHandler = async (data: RawData, isBinary: boolean) => {
|
|
if (isBinary) {
|
|
const base64Data = (data as Buffer).toString("base64");
|
|
grokWs.send(JSON.stringify({ type: "input_audio_buffer.append", audio: base64Data }));
|
|
|
|
if (isDev && connectionPcmFile) {
|
|
await connectionPcmFile.write(data as Buffer);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let message: any;
|
|
try {
|
|
message = JSON.parse((data as Buffer).toString("utf-8"));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (message?.type !== "instruction") return;
|
|
|
|
if (message.msg === "end_of_speech") {
|
|
grokWs.send(JSON.stringify({ type: "input_audio_buffer.commit" }));
|
|
grokWs.send(JSON.stringify({ type: "response.create" }));
|
|
grokWs.send(JSON.stringify({ type: "input_audio_buffer.clear" }));
|
|
} else if (message.msg === "INTERRUPT") {
|
|
grokWs.send(JSON.stringify({ type: "input_audio_buffer.clear" }));
|
|
}
|
|
};
|
|
|
|
ws.on("message", (data: RawData, isBinary: boolean) => {
|
|
if (!isConnected) {
|
|
messageQueue.push(data);
|
|
} else {
|
|
messageHandler(data, isBinary);
|
|
}
|
|
});
|
|
|
|
ws.on("error", (error: any) => {
|
|
console.error("ESP32 WebSocket error:", error);
|
|
grokWs.close();
|
|
});
|
|
|
|
ws.on("close", async (code: number, reason: string) => {
|
|
console.log(`ESP32 WebSocket closed with code ${code}, reason: ${reason}`);
|
|
await closeHandler();
|
|
opus.close();
|
|
grokWs.close();
|
|
if (isDev && connectionPcmFile) {
|
|
connectionPcmFile.close();
|
|
}
|
|
});
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error("Grok connection timeout")), 10000);
|
|
grokWs.on("open", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
grokWs.on("error", (error: any) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
});
|
|
};
|