Skip to content

Commit 26c7364

Browse files
committed
Added support for new RTVI send-file feature
fix rebase mangling
1 parent 206fed5 commit 26c7364

2 files changed

Lines changed: 301 additions & 3 deletions

File tree

client-js/client/client.ts

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ import {
2929
LLMFunctionCallStartedData,
3030
LLMFunctionCallStoppedData,
3131
MediaState,
32+
MimeTypeMapping,
3233
Participant,
3334
PipecatMetricsData,
3435
RTVI_PROTOCOL_VERSION,
3536
RTVIEvent,
3637
RTVIEvents,
38+
RTVIFile,
39+
RTVIFileFormat,
3740
RTVIMessage,
3841
RTVIMessageType,
42+
SendFileOptions,
3943
SendTextOptions,
4044
setAboutClient,
4145
TranscriptData,
@@ -1091,7 +1095,9 @@ export class PipecatClient extends RTVIEventEmitter {
10911095
public cancelUIJobGroup(jobId: string, reason?: string): void {
10921096
const payload: UICancelJobGroupData = { job_id: jobId };
10931097
if (reason !== undefined) payload.reason = reason;
1094-
this._sendMessage(new RTVIMessage(RTVIMessageType.UI_CANCEL_JOB_GROUP, payload));
1098+
this._sendMessage(
1099+
new RTVIMessage(RTVIMessageType.UI_CANCEL_JOB_GROUP, payload)
1100+
);
10951101
}
10961102

10971103
/**
@@ -1155,6 +1161,187 @@ export class PipecatClient extends RTVIEventEmitter {
11551161
);
11561162
}
11571163

1164+
@transportReady
1165+
public async sendFile(
1166+
file: RTVIFile | File,
1167+
content: string,
1168+
options: SendFileOptions = {}
1169+
) {
1170+
if (
1171+
this._botVersion[0] < 1 ||
1172+
(this._botVersion[0] === 1 && this._botVersion[1] < 3)
1173+
) {
1174+
throw new RTVIErrors.UnsupportedFeatureError(
1175+
"sendFile",
1176+
"bot",
1177+
"requires RTVI protocol 2.2.0+"
1178+
);
1179+
}
1180+
let rtvi_file = file instanceof File ? ({} as RTVIFile) : file;
1181+
let mimeType: string =
1182+
file instanceof File ? file.type : rtvi_file.format.toLowerCase();
1183+
if (mimeType in MimeTypeMapping) {
1184+
mimeType = MimeTypeMapping[mimeType as RTVIFileFormat];
1185+
}
1186+
rtvi_file.format = mimeType;
1187+
1188+
const sendFileMessage = async () => {
1189+
await this._sendMessage(
1190+
new RTVIMessage(RTVIMessageType.SEND_FILE, {
1191+
file: rtvi_file,
1192+
content,
1193+
options,
1194+
})
1195+
);
1196+
};
1197+
1198+
let uploadFile: File | undefined;
1199+
if (file instanceof File) {
1200+
// Estimate the message size with base64 encoding overhead (~33% larger)
1201+
// Add buffer for message wrapper overhead. This saves us from having to
1202+
// unnecessarily read the file into memory and encode it to base64.
1203+
const estimatedEncodedSize = Math.ceil(file.size * 1.37) + 1000;
1204+
1205+
if (estimatedEncodedSize > this._transport.maxMessageSize) {
1206+
uploadFile = file;
1207+
} else {
1208+
return new Promise<void>((resolve) => {
1209+
const reader = new FileReader();
1210+
reader.onload = async (e) => {
1211+
if (!e.target?.result) {
1212+
throw new RTVIErrors.RTVIError("Could not read file data");
1213+
}
1214+
const fileContent = e.target.result as string;
1215+
1216+
rtvi_file = {
1217+
format: file.type,
1218+
source: {
1219+
type: "bytes",
1220+
bytes: fileContent,
1221+
},
1222+
};
1223+
await sendFileMessage();
1224+
resolve();
1225+
};
1226+
1227+
reader.readAsDataURL(file);
1228+
});
1229+
}
1230+
} else if (rtvi_file.source.type === "bytes") {
1231+
const estimatedSize = rtvi_file.source.bytes.length + 1000;
1232+
if (estimatedSize > this._transport.maxMessageSize) {
1233+
// Convert bytes to File and upload
1234+
const byteString = atob(
1235+
rtvi_file.source.bytes.split(",")[1] || rtvi_file.source.bytes
1236+
);
1237+
const ab = new ArrayBuffer(byteString.length);
1238+
const ia = new Uint8Array(ab);
1239+
for (let i = 0; i < byteString.length; i++) {
1240+
ia[i] = byteString.charCodeAt(i);
1241+
}
1242+
const blob = new Blob([ab], { type: mimeType });
1243+
uploadFile = new File([blob], rtvi_file.name || "uploaded_file", {
1244+
type: mimeType,
1245+
});
1246+
}
1247+
}
1248+
1249+
if (uploadFile) {
1250+
// File is too large for transport, upload it first
1251+
rtvi_file = await this.uploadFile(uploadFile);
1252+
}
1253+
1254+
await sendFileMessage();
1255+
}
1256+
1257+
/**
1258+
* Upload a file to a specified endpoint or the default files endpoint.
1259+
* @param file - The File to upload
1260+
* @param uploadFileParams - Optional APIRequest. If not provided, constructs
1261+
* endpoint from startBotParams.endpoint by replacing the path with /files
1262+
* @returns Promise resolving to RTVIFile with name, format, and FileUrl source
1263+
*/
1264+
public async uploadFile(
1265+
file: File,
1266+
uploadFileParams?: APIRequest
1267+
): Promise<RTVIFile> {
1268+
let uploadUrl: string;
1269+
let headers: Headers | undefined;
1270+
let timeout: number | undefined;
1271+
1272+
if (uploadFileParams) {
1273+
const { endpoint } = uploadFileParams;
1274+
headers = uploadFileParams.headers;
1275+
timeout = uploadFileParams.timeout;
1276+
1277+
if (endpoint instanceof URL) {
1278+
uploadUrl = endpoint.toString();
1279+
} else if (typeof endpoint === "string") {
1280+
uploadUrl = endpoint;
1281+
} else if (
1282+
typeof Request !== "undefined" &&
1283+
endpoint instanceof Request
1284+
) {
1285+
uploadUrl = endpoint.url;
1286+
} else {
1287+
throw new RTVIErrors.RTVIError(
1288+
"Unable to determine URL from uploadFileParams.endpoint"
1289+
);
1290+
}
1291+
} else {
1292+
// Construct from startBotParams
1293+
const startBotParams = this._transport.startBotParams;
1294+
if (!startBotParams?.endpoint) {
1295+
throw new RTVIErrors.RTVIError(
1296+
"No uploadFileParams provided and no startBotParams.endpoint available"
1297+
);
1298+
}
1299+
1300+
timeout = startBotParams.timeout;
1301+
1302+
let baseUrl: URL;
1303+
if (startBotParams.endpoint instanceof URL) {
1304+
baseUrl = startBotParams.endpoint;
1305+
headers = startBotParams.headers;
1306+
} else if (typeof startBotParams.endpoint === "string") {
1307+
baseUrl = new URL(startBotParams.endpoint);
1308+
headers = startBotParams.headers;
1309+
} else if (
1310+
typeof Request !== "undefined" &&
1311+
startBotParams.endpoint instanceof Request
1312+
) {
1313+
baseUrl = new URL(startBotParams.endpoint.url);
1314+
headers = new Headers(startBotParams.endpoint.headers);
1315+
} else {
1316+
throw new RTVIErrors.RTVIError(
1317+
"Unable to determine base URL from startBotParams.endpoint"
1318+
);
1319+
}
1320+
1321+
// Change the path to /files
1322+
uploadUrl = `${baseUrl.origin}/files`;
1323+
}
1324+
1325+
// Create FormData with the file
1326+
const formData = new FormData();
1327+
formData.append("file", file);
1328+
1329+
// Create the Request object
1330+
// Note: Don't set Content-Type header - browser sets it automatically with boundary
1331+
const request = new Request(uploadUrl, {
1332+
method: "POST",
1333+
mode: "cors",
1334+
body: formData,
1335+
headers: headers ? Object.fromEntries(headers.entries()) : undefined,
1336+
});
1337+
1338+
const response = await makeRequest(
1339+
{ endpoint: request, timeout },
1340+
this._abortController
1341+
);
1342+
return response as RTVIFile;
1343+
}
1344+
11581345
/**
11591346
* Disconnects the bot, but keeps the session alive
11601347
*/
@@ -1174,7 +1361,7 @@ export class PipecatClient extends RTVIEventEmitter {
11741361
: [0, 0, 0];
11751362
this._botVersion = botVersion;
11761363
logger.debug(`[Pipecat Client] Bot is ready. Version: ${data.version}`);
1177-
if (botVersion[0] < 2) {
1364+
if (this._botVersion[0] < 2) {
11781365
logger.warn(
11791366
`[Pipecat Client] Bot protocol version ${data.version} is older than this client (${RTVI_PROTOCOL_VERSION}). Compatibility issues may occur.`
11801367
);

client-js/rtvi/messages.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import type { A11ySnapshot, UIJobGroupEnvelope } from "./ui";
1414

1515
// Protocol 2.0.0 adds server-driven bot-output progress (spoken_progress, segment_id).
16-
export const RTVI_PROTOCOL_VERSION = "2.1.0";
16+
export const RTVI_PROTOCOL_VERSION = "2.2.0";
1717
export const RTVI_MESSAGE_LABEL = "rtvi-ai";
1818

1919
/**
@@ -27,6 +27,7 @@ export enum RTVIMessageType {
2727
// Client-to-server messages
2828
CLIENT_MESSAGE = "client-message",
2929
SEND_TEXT = "send-text",
30+
SEND_FILE = "send-file",
3031
DTMF = "dtmf",
3132
// UI Worker Protocol (client-to-server)
3233
UI_EVENT = "ui-event",
@@ -262,6 +263,116 @@ export type SendTextOptions = {
262263
audio_response?: boolean;
263264
};
264265

266+
type Serializable =
267+
| string
268+
| number
269+
| boolean
270+
| null
271+
| Serializable[]
272+
| { [key: number | string]: Serializable };
273+
274+
export type RTVIImageFormat =
275+
| "png"
276+
| "jpg"
277+
| "jpeg"
278+
| "webp"
279+
| "gif"
280+
| "heic"
281+
| "hief";
282+
export type RTVIDocFormat =
283+
| "pdf"
284+
| "csv"
285+
| "txt"
286+
| "md"
287+
| "doc"
288+
| "docx"
289+
| "xls"
290+
| "xlsx"
291+
| "json"
292+
| "html"
293+
| "css"
294+
| "javascript";
295+
export type RTVIMediaFormat =
296+
| "mp3"
297+
| "wav"
298+
| "ogg"
299+
| "aac"
300+
| "mp4"
301+
| "webm"
302+
| "ogg"
303+
| "avi";
304+
export type RTVIFileFormat = RTVIImageFormat | RTVIDocFormat | RTVIMediaFormat;
305+
306+
export const MimeTypeMapping: Record<RTVIFileFormat, string> = {
307+
// Images
308+
png: "image/png",
309+
jpg: "image/jpeg",
310+
jpeg: "image/jpeg",
311+
webp: "image/webp",
312+
gif: "image/gif",
313+
heic: "image/heic",
314+
hief: "image/heif",
315+
// Documents
316+
pdf: "application/pdf",
317+
csv: "text/csv",
318+
txt: "text/plain",
319+
md: "text/markdown",
320+
doc: "application/msword",
321+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
322+
xls: "application/vnd.ms-excel",
323+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
324+
json: "application/json",
325+
html: "text/html",
326+
css: "text/css",
327+
javascript: "application/javascript",
328+
// Media
329+
mp3: "audio/mpeg",
330+
wav: "audio/wav",
331+
ogg: "audio/ogg",
332+
aac: "audio/aac",
333+
mp4: "video/mp4",
334+
webm: "video/webm",
335+
avi: "video/x-msvideo",
336+
};
337+
338+
export type FileSourceType = "bytes" | "url" | "id";
339+
340+
export type FileBytes = {
341+
type: Extract<FileSourceType, "bytes">;
342+
bytes: string;
343+
width?: number;
344+
height?: number;
345+
};
346+
export type FileUrl = {
347+
type: Extract<FileSourceType, "url">;
348+
url: string | URL;
349+
};
350+
export type FileId = {
351+
type: Extract<FileSourceType, "id">;
352+
id: string;
353+
};
354+
355+
export type RTVIFile = {
356+
name?: string;
357+
// RTVI definition takes the Mime type here, but in client-js, we support
358+
// clients providing shorthands defined above and we map them to Mime types
359+
format: string;
360+
source: FileBytes | FileUrl | FileId;
361+
};
362+
363+
export type SendFileOptions = {
364+
run_immediately?: boolean;
365+
audio_response?: boolean;
366+
// for things like 'detail' in openAI or 'citations' in Bedrock
367+
custom_options?: { [key: number | string]: Serializable };
368+
};
369+
370+
export type FileSupport = {
371+
formats: string[];
372+
sources: FileSourceType[];
373+
maxSize: number; // bytes
374+
};
375+
265376
/** Valid DTMF keypad keys. */
266377
export type DTMFButton =
267378
| "0"

0 commit comments

Comments
 (0)