Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/multi-file-upload-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@elevenlabs/client": minor
"@elevenlabs/react": minor
"@elevenlabs/types": patch
---

Add `fileIds` to `sendMultimodalMessage` and dual-send `file` + `files` on the wire.
5 changes: 5 additions & 0 deletions .changeset/multi-file-upload-widget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elevenlabs/convai-widget-core": minor
---

Allow attaching multiple files to a single multimodal message.
74 changes: 74 additions & 0 deletions packages/client/src/BaseConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,80 @@ describe("BaseConversation", () => {
});
});

describe("sendMultimodalMessage", () => {
function conversationSending() {
const sendMessage = vi.fn();
const connection = {
...noopConnection,
sendMessage,
} as unknown as BaseConnection;
return {
sendMessage,
conversation: TestConversation.create({}, connection),
};
}

it("dual-sends file and files when fileId is set", () => {
const { sendMessage, conversation } = conversationSending();

conversation.sendMultimodalMessage({
text: "What is this?",
fileId: "file_a",
});

expect(JSON.parse(JSON.stringify(sendMessage.mock.calls[0][0]))).toEqual({
type: "multimodal_message",
text: { type: "user_message", text: "What is this?" },
file: { type: "file_input", file_id: "file_a" },
files: [{ type: "file_input", file_id: "file_a" }],
});
});

it("dual-sends file and files when fileIds is set", () => {
const { sendMessage, conversation } = conversationSending();

conversation.sendMultimodalMessage({ fileIds: ["file_a", "file_b"] });

expect(JSON.parse(JSON.stringify(sendMessage.mock.calls[0][0]))).toEqual({
type: "multimodal_message",
file: { type: "file_input", file_id: "file_a" },
files: [
{ type: "file_input", file_id: "file_a" },
{ type: "file_input", file_id: "file_b" },
],
});
});

it("prefers fileIds when both fileId and fileIds are set", () => {
const { sendMessage, conversation } = conversationSending();

conversation.sendMultimodalMessage({
fileId: "ignored",
fileIds: ["file_a", "file_b"],
});

expect(JSON.parse(JSON.stringify(sendMessage.mock.calls[0][0]))).toEqual({
type: "multimodal_message",
file: { type: "file_input", file_id: "file_a" },
files: [
{ type: "file_input", file_id: "file_a" },
{ type: "file_input", file_id: "file_b" },
],
});
});

it("omits file fields for text-only messages", () => {
const { sendMessage, conversation } = conversationSending();

conversation.sendMultimodalMessage({ text: "Hello" });

expect(JSON.parse(JSON.stringify(sendMessage.mock.calls[0][0]))).toEqual({
type: "multimodal_message",
text: { type: "user_message", text: "Hello" },
});
});
});

describe("ping events", () => {
it("replies with a pong and forwards the payload to onPing", async () => {
const onPing = vi.fn();
Expand Down
16 changes: 13 additions & 3 deletions packages/client/src/BaseConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ export type PartialOptions = SessionConfig &

export type MultimodalMessageInput = {
text?: string;
/** @deprecated Use `fileIds`. */
fileId?: string;
fileIds?: string[];
};

/**
Expand Down Expand Up @@ -873,14 +875,22 @@ export abstract class BaseConversation {
}

public sendMultimodalMessage(options: MultimodalMessageInput) {
const fileIds = options.fileIds?.length
? options.fileIds
: options.fileId
? [options.fileId]
: [];
const files = fileIds.map(file_id => ({
type: "file_input" as const,
file_id,
}));
this.connection.sendMessage({
type: "multimodal_message",
text: options.text
? { type: "user_message" as const, text: options.text }
: undefined,
file: options.fileId
? { type: "file_input" as const, file_id: options.fileId }
: undefined,
file: files[0],
files: files.length ? files : undefined,
});
}

Expand Down
11 changes: 7 additions & 4 deletions packages/convai-widget-core/src/contexts/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type TranscriptEntry =
conversationIndex: number;
eventId?: number;
fileInput?: TranscriptFileInput | null;
fileInputs?: TranscriptFileInput[] | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to remove fileInput and just have fileInputs here since this type isn't exported from the package.

}
| {
type: "agent_tool_request";
Expand Down Expand Up @@ -591,14 +592,15 @@ function useConversationSetup() {
},
sendMultimodalMessage: (input: {
text?: string;
file: TranscriptFileInput & { fileId: string };
files: Array<TranscriptFileInput & { fileId: string }>;
}) => {
if (isWaitingForAgent.peek()) return;
const trimmed = input.text?.trim() ?? "";
const { fileId, ...fileInput } = input.file;
const fileIds = input.files.map(file => file.fileId);
const fileInputs = input.files.map(({ fileId: _fileId, ...fileInput }) => fileInput);
conversationRef.current?.sendMultimodalMessage({
text: trimmed || undefined,
fileId,
fileIds,
});
transcript.value = [
...transcript.value,
Expand All @@ -608,7 +610,8 @@ function useConversationSetup() {
message: trimmed,
isText: true,
conversationIndex: conversationIndex.peek(),
fileInput,
fileInput: fileInputs[0] ?? null,
fileInputs,
},
];
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type DisplayTranscriptEntry =
eventId?: number;
toolStatus?: ToolCallStatusType;
fileInput?: TranscriptFileInput | null;
fileInputs?: TranscriptFileInput[] | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, let's see if we can remove fileInput in favor of just fileInputs

}
| {
type: "disconnection";
Expand Down
43 changes: 25 additions & 18 deletions packages/convai-widget-core/src/widget/SheetActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ export function SheetActions({
// conversation's id, which would cause uploads to target the wrong endpoint.
const conversationId = status.value === "connected" ? lastId.value : null;
const {
pendingFile,
pendingFiles,
isUploading,
hasReachedLimit,
addFile,
removeFile,
markFileAsSent,
markFilesAsSent,
} = useFileUpload({ conversationId, maxFiles: maxFiles.value });

// File upload is only exposed alongside the text input — without a
Expand All @@ -88,7 +88,6 @@ export function SheetActions({
);
const uploadEnabled = useComputed(
() =>
!pendingFile.value &&
!hasReachedLimit.value &&
status.value === "connected" &&
!isWaitingForAgent.value
Expand All @@ -113,10 +112,12 @@ export function SheetActions({

const canSend = useComputed(() => {
const hasText = !!userMessage.value.trim();
const hasReadyFile = pendingFile.value?.status === "ready";
const readyFiles = pendingFiles.value.filter(file => file.status === "ready");
const hasError = pendingFiles.value.some(file => file.status === "error");
return (
(hasText || hasReadyFile) &&
(hasText || readyFiles.length > 0) &&
!isUploading.value &&
!hasError &&
!isWaitingForAgent.value
);
});
Expand All @@ -131,20 +132,23 @@ export function SheetActions({
if (!canSend.peek()) return;

const message = userMessage.value.trim();
const pending = pendingFile.value;
const readyFiles = pendingFiles.value.filter(
(file): file is Extract<typeof file, { status: "ready" }> =>
file.status === "ready"
);

if (pending?.status === "ready" && !isDisconnected.value) {
if (readyFiles.length > 0 && !isDisconnected.value) {
scrollPinned.value = true;
sendMultimodalMessage({
text: message || undefined,
file: {
files: readyFiles.map(pending => ({
fileId: pending.fileId,
fileName: pending.file.name,
mimeType: pending.file.type,
previewUrl: pending.previewUrl,
},
})),
});
markFileAsSent();
markFilesAsSent();
userMessage.value = "";
return;
}
Expand All @@ -166,8 +170,8 @@ export function SheetActions({
startSession,
sendUserMessage,
sendMultimodalMessage,
pendingFile,
markFileAsSent,
pendingFiles,
markFilesAsSent,
canSend,
]
);
Expand All @@ -188,12 +192,15 @@ export function SheetActions({
isFocused.value && "ring-2 ring-accent"
)}
>
{pendingFile.value && (
<div className="px-3 pt-3">
<PendingFilePreview
pendingFile={pendingFile.value}
onRemove={removeFile}
/>
{pendingFiles.value.length > 0 && (
<div className="px-3 pt-3 flex flex-wrap gap-2">
{pendingFiles.value.map(pendingFile => (
<PendingFilePreview
key={`${pendingFile.file.name}-${pendingFile.file.size}`}
pendingFile={pendingFile}
onRemove={() => removeFile(pendingFile.file)}
/>
Comment thread
cursor[bot] marked this conversation as resolved.
))}
</div>
)}
<SheetTextarea
Expand Down
7 changes: 4 additions & 3 deletions packages/convai-widget-core/src/widget/TranscriptMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function UserMessageBubble({
entry: Extract<DisplayTranscriptEntry, { type: "message" }>;
}) {
const { previewUrl } = useAvatarConfig();
const fileInput = entry.fileInput;
const fileInputs = entry.fileInputs ?? (entry.fileInput ? [entry.fileInput] : []);

return (
<div
Expand All @@ -86,13 +86,14 @@ function UserMessageBubble({
/>
)}
<div className="flex flex-col items-end gap-1.5 min-w-0">
{fileInput && (
{fileInputs.map(fileInput => (
<FileAttachment
key={`${fileInput.fileName}-${fileInput.previewUrl ?? ""}`}
fileName={fileInput.fileName}
mimeType={fileInput.mimeType}
previewUrl={fileInput.previewUrl}
/>
)}
))}
{entry.message && (
<div
dir="auto"
Expand Down
7 changes: 3 additions & 4 deletions packages/convai-widget-core/src/widget/UploadFileButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ export function UploadFileButton({
const handleFileChange = useCallback(
(e: Event) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
onFileSelect(file);
}
const selected = input.files ? Array.from(input.files) : [];
selected.forEach(file => onFileSelect(file));
input.value = "";
},
[onFileSelect]
Expand All @@ -52,6 +50,7 @@ export function UploadFileButton({
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_EXTENSIONS.join(",")}
multiple
className="hidden"
onChange={handleFileChange}
/>
Expand Down
Loading
Loading