Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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.
14 changes: 9 additions & 5 deletions packages/convai-widget-core/src/contexts/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface ConversationProviderProps {

/** File metadata stored alongside a user message in the local transcript. */
export type TranscriptFileInput = {
id: string;
fileName: string;
mimeType: string;
previewUrl: string | null;
Expand All @@ -139,7 +140,7 @@ export type TranscriptEntry =
isText: boolean;
conversationIndex: number;
eventId?: number;
fileInput?: TranscriptFileInput | null;
fileInputs?: TranscriptFileInput[] | null;
}
| {
type: "agent_tool_request";
Expand Down Expand Up @@ -771,14 +772,17 @@ 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 @@ -788,7 +792,7 @@ function useConversationSetup() {
message: trimmed,
isText: true,
conversationIndex: conversationIndex.peek(),
fileInput,
fileInputs,
},
];
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type DisplayTranscriptEntry =
conversationIndex: number;
eventId?: number;
toolStatus?: ToolCallStatusType;
fileInput?: TranscriptFileInput | null;
fileInputs?: TranscriptFileInput[] | null;
}
| {
type: "disconnection";
Expand Down
46 changes: 28 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,14 @@ 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 +134,24 @@ 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 => ({
id: pending.id,
fileId: pending.fileId,
fileName: pending.file.name,
mimeType: pending.file.type,
previewUrl: pending.previewUrl,
},
})),
});
markFileAsSent();
markFilesAsSent();
userMessage.value = "";
return;
}
Expand All @@ -166,8 +173,8 @@ export function SheetActions({
startSession,
sendUserMessage,
sendMultimodalMessage,
pendingFile,
markFileAsSent,
pendingFiles,
markFilesAsSent,
canSend,
]
);
Expand All @@ -188,12 +195,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.id}
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 ?? [];

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.id}
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