mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-07-21 21:13:45 +08:00
🐛 Fix embedded audio uploads (#2523)
- Keep CORS headers on upload proxy error responses. - Surface failed audio/file uploads as Typebot errors instead of leaving the recorder stuck. - Prevent recorded-audio Send clicks from submitting the host page form in embedded bots. - Prevent duplicate embed uploads while an upload is already in flight. - Disable recorder abort while recorded audio is being processed or uploaded. - Bump @typebot.io/js and @typebot.io/react to 0.10.4.
This commit is contained in:
parent
b252a9c996
commit
c82ac4324a
@ -35,6 +35,10 @@ const handleUploadRequest = async (request: Request, context: RouteContext) => {
|
||||
headers: createUploadProxyCorsHeaders(request),
|
||||
});
|
||||
|
||||
throw error;
|
||||
console.error(error);
|
||||
return new Response("Could not upload file", {
|
||||
status: 500,
|
||||
headers: createUploadProxyCorsHeaders(request),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -35,6 +35,10 @@ const handleUploadRequest = async (request: Request, context: RouteContext) => {
|
||||
headers: createUploadProxyCorsHeaders(request),
|
||||
});
|
||||
|
||||
throw error;
|
||||
console.error(error);
|
||||
return new Response("Could not upload file", {
|
||||
status: 500,
|
||||
headers: createUploadProxyCorsHeaders(request),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
4
bun.lock
4
bun.lock
@ -601,7 +601,7 @@
|
||||
},
|
||||
"packages/embeds/js": {
|
||||
"name": "@typebot.io/js",
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.4",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/ui-utils": "^1.2.11",
|
||||
"@ark-ui/solid": "^5.19.0",
|
||||
@ -636,7 +636,7 @@
|
||||
},
|
||||
"packages/embeds/react": {
|
||||
"name": "@typebot.io/react",
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.4",
|
||||
"dependencies": {
|
||||
"@typebot.io/js": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typebot.io/js",
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.4",
|
||||
"description": "Javascript library to display typebots on your website",
|
||||
"license": "FSL-1.1-ALv2",
|
||||
"type": "module",
|
||||
|
||||
@ -26,7 +26,7 @@ export const SendButton = (props: SendButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
type="submit"
|
||||
type={buttonProps.type ?? "submit"}
|
||||
class={cx(buttonProps.class, "flex items-center")}
|
||||
aria-label={showIcon ? "Send" : undefined}
|
||||
>
|
||||
|
||||
@ -61,9 +61,17 @@ export const uploadFiles = async ({
|
||||
presignedUrl: data.presignedUrl,
|
||||
formData: data.formData,
|
||||
file,
|
||||
}).catch((error) => {
|
||||
errors.push(parseUploadError(error));
|
||||
return;
|
||||
});
|
||||
|
||||
if (!upload.ok) continue;
|
||||
if (!upload) continue;
|
||||
|
||||
if (!upload.ok) {
|
||||
errors.push(await parseUploadResponseError(upload));
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.push({ url: data.fileUrl, type: file.type });
|
||||
}
|
||||
@ -71,3 +79,16 @@ export const uploadFiles = async ({
|
||||
? { type: "error", error: errors.join(", ") }
|
||||
: { type: "success", urls };
|
||||
};
|
||||
|
||||
const parseUploadResponseError = async (response: Response) => {
|
||||
const body = await response.text().catch(() => undefined);
|
||||
|
||||
return (
|
||||
body ||
|
||||
response.statusText ||
|
||||
`Upload failed with status ${response.status}`
|
||||
);
|
||||
};
|
||||
|
||||
const parseUploadError = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Could not upload file";
|
||||
|
||||
@ -46,6 +46,7 @@ export const TextInput = (props: Props) => {
|
||||
const [uploadProgress, setUploadProgress] = createSignal<
|
||||
{ fileIndex: number; progress: number } | undefined
|
||||
>(undefined);
|
||||
const [isUploading, setIsUploading] = createSignal(false);
|
||||
const [isDraggingOver, setIsDraggingOver] = createSignal(false);
|
||||
const [recordingStatus, setRecordingStatus] = createSignal<
|
||||
"started" | "asking" | "stopped"
|
||||
@ -60,43 +61,57 @@ export const TextInput = (props: Props) => {
|
||||
inputRef?.value !== "" && inputRef?.reportValidity();
|
||||
|
||||
const submit = async () => {
|
||||
if (isUploading()) return;
|
||||
if (recordingStatus() === "started" && mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
|
||||
return;
|
||||
}
|
||||
if (checkIfInputIsValid()) {
|
||||
let attachments: Attachment[] | undefined;
|
||||
if (selectedFiles().length > 0) {
|
||||
setUploadProgress(undefined);
|
||||
const result = await uploadFiles({
|
||||
apiHost:
|
||||
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
|
||||
files: selectedFiles().map((file) => ({
|
||||
file: file,
|
||||
input: {
|
||||
blockId: props.block.id,
|
||||
sessionId: props.context.sessionId,
|
||||
fileName: file.name,
|
||||
},
|
||||
})),
|
||||
onUploadProgress: setUploadProgress,
|
||||
});
|
||||
if (result.type === "error") {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(undefined);
|
||||
const result = await uploadFiles({
|
||||
apiHost:
|
||||
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
|
||||
files: selectedFiles().map((file) => ({
|
||||
file: file,
|
||||
input: {
|
||||
blockId: props.block.id,
|
||||
sessionId: props.context.sessionId,
|
||||
fileName: file.name,
|
||||
},
|
||||
})),
|
||||
onUploadProgress: setUploadProgress,
|
||||
}).finally(() => {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(undefined);
|
||||
});
|
||||
|
||||
if (result.type === "error") {
|
||||
toaster.create({
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
attachments = result.urls
|
||||
?.map((urls, index) =>
|
||||
urls
|
||||
? {
|
||||
...urls,
|
||||
blobUrl: URL.createObjectURL(selectedFiles()[index]),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
.filter(isDefined);
|
||||
} catch (error) {
|
||||
toaster.create({
|
||||
description: result.error,
|
||||
description:
|
||||
error instanceof Error ? error.message : "Could not upload file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
attachments = result.urls
|
||||
?.map((urls, index) =>
|
||||
urls
|
||||
? {
|
||||
...urls,
|
||||
blobUrl: URL.createObjectURL(selectedFiles()[index]),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
.filter(isDefined);
|
||||
}
|
||||
props.onSubmit({
|
||||
type: "text",
|
||||
@ -186,6 +201,7 @@ export const TextInput = (props: Props) => {
|
||||
};
|
||||
|
||||
const recordVoice = () => {
|
||||
if (isUploading()) return;
|
||||
setRecordingStatus("asking");
|
||||
};
|
||||
|
||||
@ -207,58 +223,82 @@ export const TextInput = (props: Props) => {
|
||||
if (recordingStatus() !== "started" || recordedChunks.length === 0)
|
||||
return;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(undefined);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const blob = await fixWebmDuration(
|
||||
new Blob(recordedChunks, { type: mimeType }),
|
||||
duration,
|
||||
);
|
||||
const blob = await fixWebmDuration(
|
||||
new Blob(recordedChunks, { type: mimeType }),
|
||||
duration,
|
||||
);
|
||||
|
||||
const audioFile = new File(
|
||||
[blob],
|
||||
`rec-${props.block.id}-${Date.now()}.${
|
||||
mimeType === "audio/webm" ? "webm" : "mp4"
|
||||
}`,
|
||||
{
|
||||
type: mimeType,
|
||||
},
|
||||
);
|
||||
|
||||
setUploadProgress(undefined);
|
||||
const result = await uploadFiles({
|
||||
apiHost:
|
||||
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
|
||||
files: [
|
||||
const audioFile = new File(
|
||||
[blob],
|
||||
`rec-${props.block.id}-${Date.now()}.${
|
||||
mimeType === "audio/webm" ? "webm" : "mp4"
|
||||
}`,
|
||||
{
|
||||
file: audioFile,
|
||||
input: {
|
||||
blockId: props.block.id,
|
||||
sessionId: props.context.sessionId,
|
||||
fileName: audioFile.name,
|
||||
},
|
||||
type: mimeType,
|
||||
},
|
||||
],
|
||||
onUploadProgress: setUploadProgress,
|
||||
});
|
||||
if (result.type === "error") {
|
||||
toaster.create({
|
||||
description: result.error,
|
||||
);
|
||||
|
||||
const result = await uploadFiles({
|
||||
apiHost:
|
||||
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
|
||||
files: [
|
||||
{
|
||||
file: audioFile,
|
||||
input: {
|
||||
blockId: props.block.id,
|
||||
sessionId: props.context.sessionId,
|
||||
fileName: audioFile.name,
|
||||
},
|
||||
},
|
||||
],
|
||||
onUploadProgress: setUploadProgress,
|
||||
}).finally(() => {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(undefined);
|
||||
});
|
||||
|
||||
if (result.type === "error") {
|
||||
setRecordingStatus("stopped");
|
||||
toaster.create({
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = result.urls.find(isDefined)?.url;
|
||||
if (!url) {
|
||||
setRecordingStatus("stopped");
|
||||
toaster.create({
|
||||
description: "Could not upload audio file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
props.onSubmit({
|
||||
type: "recording",
|
||||
url,
|
||||
blobUrl: URL.createObjectURL(audioFile),
|
||||
});
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(undefined);
|
||||
setRecordingStatus("stopped");
|
||||
toaster.create({
|
||||
description:
|
||||
error instanceof Error ? error.message : "Could not upload audio",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const urls = result.urls.filter(isDefined).map((url) => url.url);
|
||||
props.onSubmit({
|
||||
type: "recording",
|
||||
url: urls[0],
|
||||
blobUrl: URL.createObjectURL(audioFile),
|
||||
});
|
||||
};
|
||||
mediaRecorder.start();
|
||||
setRecordingStatus("started");
|
||||
};
|
||||
|
||||
const handleRecordingAbort = () => {
|
||||
mediaRecorder?.stop();
|
||||
if (mediaRecorder && mediaRecorder.state !== "inactive")
|
||||
mediaRecorder.stop();
|
||||
setRecordingStatus("stopped");
|
||||
mediaRecorder = undefined;
|
||||
recordedChunks = [];
|
||||
@ -287,6 +327,7 @@ export const TextInput = (props: Props) => {
|
||||
recordingStatus={recordingStatus()}
|
||||
buttonsTheme={props.context.typebot.theme.chat?.buttons}
|
||||
context={props.context}
|
||||
isAbortDisabled={isUploading()}
|
||||
onRecordingConfirmed={handleRecordingConfirmed}
|
||||
onAbortRecording={handleRecordingAbort}
|
||||
/>
|
||||
@ -370,6 +411,7 @@ export const TextInput = (props: Props) => {
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
isDisabled={isUploading()}
|
||||
class="h-14 flex items-center"
|
||||
on:click={recordVoice}
|
||||
aria-label="Record voice"
|
||||
@ -379,9 +421,10 @@ export const TextInput = (props: Props) => {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SendButton
|
||||
type="submit"
|
||||
isDisabled={Boolean(uploadProgress())}
|
||||
type="button"
|
||||
isDisabled={isUploading()}
|
||||
class="h-14"
|
||||
on:click={submit}
|
||||
>
|
||||
{props.block.options?.labels?.button}
|
||||
</SendButton>
|
||||
|
||||
@ -19,6 +19,7 @@ type Props = {
|
||||
recordingStatus: "asking" | "started" | "stopped";
|
||||
buttonsTheme: NonNullable<Theme["chat"]>["buttons"];
|
||||
context: BotContext;
|
||||
isAbortDisabled?: boolean;
|
||||
onAbortRecording: () => void;
|
||||
onRecordingConfirmed: (stream: MediaStream) => void;
|
||||
};
|
||||
@ -168,7 +169,8 @@ export const VoiceRecorder = (props: Props) => {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 rounded-full"
|
||||
class="p-0.5 rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={props.isAbortDisabled}
|
||||
on:click={stopRecording}
|
||||
aria-label="Stop recording"
|
||||
>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typebot.io/react",
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.4",
|
||||
"description": "Convenient library to display typebots on your React app",
|
||||
"license": "FSL-1.1-ALv2",
|
||||
"type": "module",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user