mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
feat: allow multiple recording file upload
This commit is contained in:
parent
95d6dd44ff
commit
6792ecd301
7 changed files with 2323 additions and 2978 deletions
|
|
@ -10,10 +10,12 @@ from api.db import db_client
|
|||
from api.db.workflow_recording_client import generate_short_id
|
||||
from api.enums import StorageBackend
|
||||
from api.schemas.workflow_recording import (
|
||||
RecordingCreateRequestSchema,
|
||||
BatchRecordingCreateRequestSchema,
|
||||
BatchRecordingCreateResponseSchema,
|
||||
BatchRecordingUploadRequestSchema,
|
||||
BatchRecordingUploadResponseSchema,
|
||||
RecordingListResponseSchema,
|
||||
RecordingResponseSchema,
|
||||
RecordingUploadRequestSchema,
|
||||
RecordingUploadResponseSchema,
|
||||
)
|
||||
from api.services.auth.depends import get_user
|
||||
|
|
@ -56,94 +58,105 @@ def _build_response(rec) -> RecordingResponseSchema:
|
|||
|
||||
@router.post(
|
||||
"/upload-url",
|
||||
response_model=RecordingUploadResponseSchema,
|
||||
summary="Get presigned URL for recording upload",
|
||||
response_model=BatchRecordingUploadResponseSchema,
|
||||
summary="Get presigned URLs for recording uploads",
|
||||
)
|
||||
async def get_upload_url(
|
||||
request: RecordingUploadRequestSchema,
|
||||
async def get_upload_urls(
|
||||
request: BatchRecordingUploadRequestSchema,
|
||||
user=Depends(get_user),
|
||||
):
|
||||
"""Generate a presigned PUT URL for uploading an audio recording."""
|
||||
"""Generate presigned PUT URLs for uploading one or more audio recordings."""
|
||||
try:
|
||||
recording_id = await _generate_unique_recording_id()
|
||||
items = []
|
||||
for fd in request.files:
|
||||
recording_id = await _generate_unique_recording_id()
|
||||
|
||||
storage_key = (
|
||||
f"recordings/{user.selected_organization_id}"
|
||||
f"/{request.workflow_id}/{recording_id}"
|
||||
f"/{request.filename}"
|
||||
)
|
||||
storage_key = (
|
||||
f"recordings/{user.selected_organization_id}"
|
||||
f"/{request.workflow_id}/{recording_id}"
|
||||
f"/{fd.filename}"
|
||||
)
|
||||
|
||||
upload_url = await storage_fs.aget_presigned_put_url(
|
||||
file_path=storage_key,
|
||||
expiration=1800, # 30 minutes
|
||||
content_type=request.mime_type,
|
||||
max_size=5_242_880, # 5MB max
|
||||
)
|
||||
upload_url = await storage_fs.aget_presigned_put_url(
|
||||
file_path=storage_key,
|
||||
expiration=1800,
|
||||
content_type=fd.mime_type,
|
||||
max_size=5_242_880,
|
||||
)
|
||||
|
||||
if not upload_url:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to generate presigned upload URL"
|
||||
if not upload_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate presigned upload URL for {fd.filename}",
|
||||
)
|
||||
|
||||
items.append(
|
||||
RecordingUploadResponseSchema(
|
||||
upload_url=upload_url,
|
||||
recording_id=recording_id,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Generated recording upload URL: {recording_id}, "
|
||||
f"Generated {len(items)} recording upload URL(s), "
|
||||
f"workflow {request.workflow_id}, org {user.selected_organization_id}"
|
||||
)
|
||||
|
||||
return RecordingUploadResponseSchema(
|
||||
upload_url=upload_url,
|
||||
recording_id=recording_id,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
return BatchRecordingUploadResponseSchema(items=items)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error generating recording upload URL: {exc}")
|
||||
logger.error(f"Error generating recording upload URLs: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to generate upload URL"
|
||||
status_code=500, detail="Failed to generate upload URLs"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=RecordingResponseSchema,
|
||||
summary="Create recording record after upload",
|
||||
response_model=BatchRecordingCreateResponseSchema,
|
||||
summary="Create recording records after upload",
|
||||
)
|
||||
async def create_recording(
|
||||
request: RecordingCreateRequestSchema,
|
||||
async def create_recordings(
|
||||
request: BatchRecordingCreateRequestSchema,
|
||||
user=Depends(get_user),
|
||||
):
|
||||
"""Create a recording record after the audio has been uploaded to storage."""
|
||||
"""Create one or more recording records after audio files have been uploaded."""
|
||||
try:
|
||||
backend = StorageBackend.get_current_backend()
|
||||
results = []
|
||||
|
||||
recording = await db_client.create_recording(
|
||||
recording_id=request.recording_id,
|
||||
workflow_id=request.workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
tts_provider=request.tts_provider,
|
||||
tts_model=request.tts_model,
|
||||
tts_voice_id=request.tts_voice_id,
|
||||
transcript=request.transcript,
|
||||
storage_key=request.storage_key,
|
||||
storage_backend=backend.value,
|
||||
created_by=user.id,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
for rec_req in request.recordings:
|
||||
recording = await db_client.create_recording(
|
||||
recording_id=rec_req.recording_id,
|
||||
workflow_id=rec_req.workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
tts_provider=rec_req.tts_provider,
|
||||
tts_model=rec_req.tts_model,
|
||||
tts_voice_id=rec_req.tts_voice_id,
|
||||
transcript=rec_req.transcript,
|
||||
storage_key=rec_req.storage_key,
|
||||
storage_backend=backend.value,
|
||||
created_by=user.id,
|
||||
metadata=rec_req.metadata,
|
||||
)
|
||||
results.append(_build_response(recording))
|
||||
|
||||
logger.info(
|
||||
f"Created recording {request.recording_id} for workflow {request.workflow_id}"
|
||||
f"Created {len(results)} recording(s) for "
|
||||
f"workflow {request.recordings[0].workflow_id}"
|
||||
)
|
||||
|
||||
return _build_response(recording)
|
||||
return BatchRecordingCreateResponseSchema(recordings=results)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error creating recording: {exc}")
|
||||
logger.error(f"Error creating recordings: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create recording"
|
||||
status_code=500, detail="Failed to create recordings"
|
||||
) from exc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,17 @@ from typing import Any, Dict, List, Optional
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RecordingUploadRequestSchema(BaseModel):
|
||||
"""Request schema for getting a presigned upload URL."""
|
||||
class RecordingUploadResponseSchema(BaseModel):
|
||||
"""Response schema with presigned upload URL."""
|
||||
|
||||
upload_url: str = Field(..., description="Presigned URL for uploading the audio")
|
||||
recording_id: str = Field(..., description="Short unique recording ID")
|
||||
storage_key: str = Field(..., description="Storage key where file will be uploaded")
|
||||
|
||||
|
||||
class FileDescriptor(BaseModel):
|
||||
"""Descriptor for a single file in a batch upload request."""
|
||||
|
||||
workflow_id: int = Field(..., description="Workflow ID this recording belongs to")
|
||||
filename: str = Field(..., description="Original filename of the audio file")
|
||||
mime_type: str = Field(
|
||||
default="audio/wav", description="MIME type of the audio file"
|
||||
|
|
@ -22,12 +29,21 @@ class RecordingUploadRequestSchema(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class RecordingUploadResponseSchema(BaseModel):
|
||||
"""Response schema with presigned upload URL."""
|
||||
class BatchRecordingUploadRequestSchema(BaseModel):
|
||||
"""Request schema for getting presigned upload URLs for one or more files."""
|
||||
|
||||
upload_url: str = Field(..., description="Presigned URL for uploading the audio")
|
||||
recording_id: str = Field(..., description="Short unique recording ID")
|
||||
storage_key: str = Field(..., description="Storage key where file will be uploaded")
|
||||
workflow_id: int = Field(..., description="Workflow ID these recordings belong to")
|
||||
files: List[FileDescriptor] = Field(
|
||||
..., min_length=1, max_length=20, description="List of files to upload"
|
||||
)
|
||||
|
||||
|
||||
class BatchRecordingUploadResponseSchema(BaseModel):
|
||||
"""Response schema with presigned upload URLs."""
|
||||
|
||||
items: List[RecordingUploadResponseSchema] = Field(
|
||||
..., description="Upload URLs for each file"
|
||||
)
|
||||
|
||||
|
||||
class RecordingCreateRequestSchema(BaseModel):
|
||||
|
|
@ -66,6 +82,22 @@ class RecordingResponseSchema(BaseModel):
|
|||
is_active: bool
|
||||
|
||||
|
||||
class BatchRecordingCreateRequestSchema(BaseModel):
|
||||
"""Request schema for creating one or more recording records after upload."""
|
||||
|
||||
recordings: List[RecordingCreateRequestSchema] = Field(
|
||||
..., min_length=1, max_length=20, description="List of recordings to create"
|
||||
)
|
||||
|
||||
|
||||
class BatchRecordingCreateResponseSchema(BaseModel):
|
||||
"""Response schema for recording creation."""
|
||||
|
||||
recordings: List[RecordingResponseSchema] = Field(
|
||||
..., description="Created recording records"
|
||||
)
|
||||
|
||||
|
||||
class RecordingListResponseSchema(BaseModel):
|
||||
"""Response schema for list of recordings."""
|
||||
|
||||
|
|
|
|||
4727
ui/package-lock.json
generated
4727
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@
|
|||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@nangohq/frontend": "^0.60.3",
|
||||
"@nangohq/frontend": "^0.69.47",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.52",
|
||||
"@stackframe/stack": "^2.8.80",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -59,13 +59,13 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@hey-api/openapi-ts": "^0.66.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Loader2, Mic, Pause, Play, Square, Trash2Icon, Upload } from "lucide-react";
|
||||
import { Loader2, Mic, Pause, Play, Square, Trash2Icon, Upload, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
createRecordingApiV1WorkflowRecordingsPost,
|
||||
createRecordingsApiV1WorkflowRecordingsPost,
|
||||
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
|
||||
getSignedUrlApiV1S3SignedUrlGet,
|
||||
getUploadUrlApiV1WorkflowRecordingsUploadUrlPost,
|
||||
getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost,
|
||||
listRecordingsApiV1WorkflowRecordingsGet,
|
||||
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
|
||||
} from "@/client";
|
||||
|
|
@ -40,7 +40,17 @@ interface RecordingsDialogProps {
|
|||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
type RecordingStep = "idle" | "naming" | "recording" | "transcribing";
|
||||
type RecordingStep = "idle" | "naming" | "recording";
|
||||
|
||||
interface PendingFile {
|
||||
id: string;
|
||||
file: File;
|
||||
transcript: string;
|
||||
isTranscribing: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let pendingFileCounter = 0;
|
||||
|
||||
export const RecordingsDialog = ({
|
||||
open,
|
||||
|
|
@ -52,8 +62,7 @@ export const RecordingsDialog = ({
|
|||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [language, setLanguage] = useState("multi");
|
||||
const [recordingStep, setRecordingStep] = useState<RecordingStep>("idle");
|
||||
|
|
@ -125,8 +134,7 @@ export const RecordingsDialog = ({
|
|||
if (open) {
|
||||
fetchRecordings();
|
||||
setError(null);
|
||||
setTranscript("");
|
||||
setSelectedFile(null);
|
||||
setPendingFiles([]);
|
||||
setLanguage("multi");
|
||||
resetRecordingState();
|
||||
}
|
||||
|
|
@ -140,8 +148,10 @@ export const RecordingsDialog = ({
|
|||
}
|
||||
}, [open, stopRecording, stopRecordingTimer, stopPlayback]);
|
||||
|
||||
const transcribeFile = async (file: File) => {
|
||||
setRecordingStep("transcribing");
|
||||
const transcribeFile = async (pendingId: string, file: File) => {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((p) => (p.id === pendingId ? { ...p, isTranscribing: true } : p))
|
||||
);
|
||||
try {
|
||||
const currentLang = languageRef.current;
|
||||
const result = await transcribeAudioApiV1WorkflowRecordingsTranscribePost({
|
||||
|
|
@ -149,16 +159,55 @@ export const RecordingsDialog = ({
|
|||
});
|
||||
const data = result.data as Record<string, unknown> | undefined;
|
||||
if (data?.transcript) {
|
||||
setTranscript(data.transcript as string);
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === pendingId ? { ...p, transcript: data.transcript as string, isTranscribing: false } : p
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((p) => (p.id === pendingId ? { ...p, isTranscribing: false } : p))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Transcription failed — user can still type manually
|
||||
setError("Auto-transcription failed. You can type the transcript manually.");
|
||||
} finally {
|
||||
setRecordingStep("idle");
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === pendingId
|
||||
? { ...p, isTranscribing: false, error: "Auto-transcription failed" }
|
||||
: p
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const addPendingFiles = (files: File[]) => {
|
||||
const valid: PendingFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const id = `pending-${++pendingFileCounter}`;
|
||||
valid.push({ id, file, transcript: "", isTranscribing: false });
|
||||
}
|
||||
if (valid.length === 0) return;
|
||||
setPendingFiles((prev) => [...prev, ...valid]);
|
||||
setError(null);
|
||||
for (const pf of valid) {
|
||||
transcribeFile(pf.id, pf.file);
|
||||
}
|
||||
};
|
||||
|
||||
const removePendingFile = (pendingId: string) => {
|
||||
setPendingFiles((prev) => prev.filter((p) => p.id !== pendingId));
|
||||
};
|
||||
|
||||
const updateTranscript = (pendingId: string, transcript: string) => {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((p) => (p.id === pendingId ? { ...p, transcript } : p))
|
||||
);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
|
@ -183,9 +232,8 @@ export const RecordingsDialog = ({
|
|||
}
|
||||
const ext = mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4";
|
||||
const file = new File([blob], `${filename}.${ext}`, { type: mediaRecorder.mimeType });
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
transcribeFile(file);
|
||||
resetRecordingState();
|
||||
addPendingFiles([file]);
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
|
|
@ -205,20 +253,15 @@ export const RecordingsDialog = ({
|
|||
stopRecording();
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File | null) => {
|
||||
if (file && file.size > MAX_FILE_SIZE) {
|
||||
setError(`File size (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds the maximum allowed size of 5MB.`);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSelectedFile(file);
|
||||
if (file) transcribeFile(file);
|
||||
const handleFileSelect = (fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
addPendingFiles(Array.from(fileList));
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !transcript.trim()) return;
|
||||
const ready = pendingFiles.filter((p) => p.transcript.trim() && !p.isTranscribing);
|
||||
if (ready.length === 0) return;
|
||||
if (!ttsProvider || !ttsModel || !ttsVoiceId) {
|
||||
setError(
|
||||
"TTS configuration (provider, model, voice) must be set in your user configuration before uploading."
|
||||
|
|
@ -230,66 +273,72 @@ export const RecordingsDialog = ({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Get presigned URL
|
||||
// Step 1: Get presigned URLs for all files
|
||||
const uploadUrlResponse =
|
||||
await getUploadUrlApiV1WorkflowRecordingsUploadUrlPost({
|
||||
await getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost({
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
filename: selectedFile.name,
|
||||
mime_type: selectedFile.type || "audio/wav",
|
||||
file_size: selectedFile.size,
|
||||
files: ready.map((p) => ({
|
||||
filename: p.file.name,
|
||||
mime_type: p.file.type || "audio/wav",
|
||||
file_size: p.file.size,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadUrlResponse.data) {
|
||||
throw new Error("Failed to get upload URL");
|
||||
if (!uploadUrlResponse.data?.items) {
|
||||
throw new Error("Failed to get upload URLs");
|
||||
}
|
||||
|
||||
const { upload_url, recording_id, storage_key } =
|
||||
uploadUrlResponse.data;
|
||||
const items = uploadUrlResponse.data.items;
|
||||
|
||||
// Step 2: Upload file directly to storage
|
||||
const uploadResponse = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: selectedFile,
|
||||
headers: {
|
||||
"Content-Type": selectedFile.type || "audio/wav",
|
||||
},
|
||||
});
|
||||
// Step 2: Upload all files to storage in parallel
|
||||
await Promise.all(
|
||||
items.map(async (item, idx) => {
|
||||
const file = ready[idx].file;
|
||||
const uploadResponse = await fetch(item.upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "audio/wav",
|
||||
},
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`File upload failed for ${file.name}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("File upload failed");
|
||||
}
|
||||
|
||||
// Step 3: Create recording record
|
||||
await createRecordingApiV1WorkflowRecordingsPost({
|
||||
// Step 3: Create all recording records
|
||||
await createRecordingsApiV1WorkflowRecordingsPost({
|
||||
body: {
|
||||
recording_id,
|
||||
workflow_id: workflowId,
|
||||
tts_provider: ttsProvider,
|
||||
tts_model: ttsModel,
|
||||
tts_voice_id: ttsVoiceId,
|
||||
transcript: transcript.trim(),
|
||||
storage_key,
|
||||
metadata: {
|
||||
original_filename: selectedFile.name,
|
||||
file_size_bytes: selectedFile.size,
|
||||
mime_type: selectedFile.type,
|
||||
language,
|
||||
},
|
||||
recordings: items.map((item, idx) => ({
|
||||
recording_id: item.recording_id,
|
||||
workflow_id: workflowId,
|
||||
tts_provider: ttsProvider,
|
||||
tts_model: ttsModel,
|
||||
tts_voice_id: ttsVoiceId,
|
||||
transcript: ready[idx].transcript.trim(),
|
||||
storage_key: item.storage_key,
|
||||
metadata: {
|
||||
original_filename: ready[idx].file.name,
|
||||
file_size_bytes: ready[idx].file.size,
|
||||
mime_type: ready[idx].file.type,
|
||||
language,
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form and refresh list
|
||||
setTranscript("");
|
||||
setSelectedFile(null);
|
||||
setPendingFiles([]);
|
||||
setLanguage("multi");
|
||||
resetRecordingState();
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
await fetchRecordings();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to upload recording"
|
||||
err instanceof Error ? err.message : "Failed to upload recordings"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
|
|
@ -335,8 +384,9 @@ export const RecordingsDialog = ({
|
|||
};
|
||||
|
||||
const isRecording = recordingStep === "recording";
|
||||
const isTranscribing = recordingStep === "transcribing";
|
||||
const isBusy = uploading || isRecording || isTranscribing;
|
||||
const anyTranscribing = pendingFiles.some((p) => p.isTranscribing);
|
||||
const readyCount = pendingFiles.filter((p) => p.transcript.trim() && !p.isTranscribing).length;
|
||||
const isBusy = uploading || isRecording || anyTranscribing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -383,19 +433,20 @@ export const RecordingsDialog = ({
|
|||
|
||||
{/* Upload Section */}
|
||||
<div className="space-y-3 border rounded-md p-3">
|
||||
<Label className="text-sm font-medium">Add New Recording</Label>
|
||||
<Label className="text-sm font-medium">Add New Recordings</Label>
|
||||
|
||||
{/* Audio source: file picker or record */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Audio File
|
||||
Audio Files
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -407,13 +458,7 @@ export const RecordingsDialog = ({
|
|||
disabled={isBusy}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2 shrink-0" />
|
||||
{selectedFile && recordingStep !== "naming" ? (
|
||||
<span className="truncate">
|
||||
{selectedFile.name} ({(selectedFile.size / (1024 * 1024)).toFixed(1)}MB)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Choose audio file (max 5MB)</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">Choose audio files (max 5MB each)</span>
|
||||
</Button>
|
||||
{recordingStep === "idle" && (
|
||||
<Button
|
||||
|
|
@ -421,7 +466,7 @@ export const RecordingsDialog = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRecordingStep("naming")}
|
||||
disabled={uploading || isTranscribing}
|
||||
disabled={uploading || anyTranscribing}
|
||||
>
|
||||
<Mic className="w-4 h-4 mr-1" />
|
||||
Record
|
||||
|
|
@ -489,11 +534,44 @@ export const RecordingsDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcribing progress */}
|
||||
{isTranscribing && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Transcribing audio...
|
||||
{/* Pending files list */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Pending ({pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""})
|
||||
</Label>
|
||||
{pendingFiles.map((pf) => (
|
||||
<div key={pf.id} className="rounded-md border p-2 space-y-1.5 bg-muted/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate flex-1">
|
||||
{pf.file.name} ({(pf.file.size / (1024 * 1024)).toFixed(1)}MB)
|
||||
</code>
|
||||
{pf.isTranscribing && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => removePendingFile(pf.id)}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{pf.error && (
|
||||
<p className="text-xs text-destructive">{pf.error}</p>
|
||||
)}
|
||||
<Textarea
|
||||
placeholder={pf.isTranscribing ? "Transcribing..." : "What does this recording say?"}
|
||||
value={pf.transcript}
|
||||
onChange={(e) => updateTranscript(pf.id, e.target.value)}
|
||||
disabled={pf.isTranscribing}
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -516,32 +594,19 @@ export const RecordingsDialog = ({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Transcript */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Transcript
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={isTranscribing ? "Transcribing..." : "What does this recording say?"}
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
disabled={isTranscribing}
|
||||
rows={3}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !transcript.trim() || isBusy}
|
||||
disabled={readyCount === 0 || isBusy}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{uploading ? "Uploading..." : "Upload Recording"}
|
||||
{uploading
|
||||
? "Uploading..."
|
||||
: `Upload ${readyCount} Recording${readyCount !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -80,6 +80,50 @@ export type AuthUserResponse = {
|
|||
is_superuser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for creating one or more recording records after upload.
|
||||
*/
|
||||
export type BatchRecordingCreateRequestSchema = {
|
||||
/**
|
||||
* List of recordings to create
|
||||
*/
|
||||
recordings: Array<RecordingCreateRequestSchema>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for recording creation.
|
||||
*/
|
||||
export type BatchRecordingCreateResponseSchema = {
|
||||
/**
|
||||
* Created recording records
|
||||
*/
|
||||
recordings: Array<RecordingResponseSchema>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for getting presigned upload URLs for one or more files.
|
||||
*/
|
||||
export type BatchRecordingUploadRequestSchema = {
|
||||
/**
|
||||
* Workflow ID these recordings belong to
|
||||
*/
|
||||
workflow_id: number;
|
||||
/**
|
||||
* List of files to upload
|
||||
*/
|
||||
files: Array<FileDescriptor>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema with presigned upload URLs.
|
||||
*/
|
||||
export type BatchRecordingUploadResponseSchema = {
|
||||
/**
|
||||
* Upload URLs for each file
|
||||
*/
|
||||
items: Array<RecordingUploadResponseSchema>;
|
||||
};
|
||||
|
||||
export type BodyTranscribeAudioApiV1WorkflowRecordingsTranscribePost = {
|
||||
file: Blob | File;
|
||||
language?: string;
|
||||
|
|
@ -634,6 +678,24 @@ export type EndCallToolDefinition = {
|
|||
config: EndCallConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Descriptor for a single file in a batch upload request.
|
||||
*/
|
||||
export type FileDescriptor = {
|
||||
/**
|
||||
* Original filename of the audio file
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* MIME type of the audio file
|
||||
*/
|
||||
mime_type?: string;
|
||||
/**
|
||||
* File size in bytes (max 5MB)
|
||||
*/
|
||||
file_size: number;
|
||||
};
|
||||
|
||||
export type FileMetadataResponse = {
|
||||
key: string;
|
||||
metadata: {
|
||||
|
|
@ -909,28 +971,6 @@ export type RecordingResponseSchema = {
|
|||
is_active: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for getting a presigned upload URL.
|
||||
*/
|
||||
export type RecordingUploadRequestSchema = {
|
||||
/**
|
||||
* Workflow ID this recording belongs to
|
||||
*/
|
||||
workflow_id: number;
|
||||
/**
|
||||
* Original filename of the audio file
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* MIME type of the audio file
|
||||
*/
|
||||
mime_type?: string;
|
||||
/**
|
||||
* File size in bytes (max 5MB)
|
||||
*/
|
||||
file_size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema with presigned upload URL.
|
||||
*/
|
||||
|
|
@ -5464,8 +5504,8 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
|
|||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses];
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostData = {
|
||||
body: RecordingUploadRequestSchema;
|
||||
export type GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostData = {
|
||||
body: BatchRecordingUploadRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
|
|
@ -5475,7 +5515,7 @@ export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostData = {
|
|||
url: '/api/v1/workflow-recordings/upload-url';
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors = {
|
||||
export type GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
|
|
@ -5486,16 +5526,16 @@ export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors = {
|
|||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostError = GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors[keyof GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors];
|
||||
export type GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostError = GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors[keyof GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors];
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses = {
|
||||
export type GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RecordingUploadResponseSchema;
|
||||
200: BatchRecordingUploadResponseSchema;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponse = GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses[keyof GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses];
|
||||
export type GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponse = GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses[keyof GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses];
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetData = {
|
||||
body?: never;
|
||||
|
|
@ -5547,8 +5587,8 @@ export type ListRecordingsApiV1WorkflowRecordingsGetResponses = {
|
|||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetResponse = ListRecordingsApiV1WorkflowRecordingsGetResponses[keyof ListRecordingsApiV1WorkflowRecordingsGetResponses];
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostData = {
|
||||
body: RecordingCreateRequestSchema;
|
||||
export type CreateRecordingsApiV1WorkflowRecordingsPostData = {
|
||||
body: BatchRecordingCreateRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
|
|
@ -5558,7 +5598,7 @@ export type CreateRecordingApiV1WorkflowRecordingsPostData = {
|
|||
url: '/api/v1/workflow-recordings/';
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostErrors = {
|
||||
export type CreateRecordingsApiV1WorkflowRecordingsPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
|
|
@ -5569,16 +5609,16 @@ export type CreateRecordingApiV1WorkflowRecordingsPostErrors = {
|
|||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostError = CreateRecordingApiV1WorkflowRecordingsPostErrors[keyof CreateRecordingApiV1WorkflowRecordingsPostErrors];
|
||||
export type CreateRecordingsApiV1WorkflowRecordingsPostError = CreateRecordingsApiV1WorkflowRecordingsPostErrors[keyof CreateRecordingsApiV1WorkflowRecordingsPostErrors];
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostResponses = {
|
||||
export type CreateRecordingsApiV1WorkflowRecordingsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RecordingResponseSchema;
|
||||
200: BatchRecordingCreateResponseSchema;
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostResponse = CreateRecordingApiV1WorkflowRecordingsPostResponses[keyof CreateRecordingApiV1WorkflowRecordingsPostResponses];
|
||||
export type CreateRecordingsApiV1WorkflowRecordingsPostResponse = CreateRecordingsApiV1WorkflowRecordingsPostResponses[keyof CreateRecordingsApiV1WorkflowRecordingsPostResponses];
|
||||
|
||||
export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData = {
|
||||
body?: never;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue