feat: add full document mode in knowledge base

This commit is contained in:
Abhishek Kumar 2026-04-09 13:49:20 +05:30
parent c085398933
commit 87c8c5e2c8
26 changed files with 1144 additions and 351 deletions

View file

@ -201,10 +201,15 @@ export default function DocumentList({ refreshTrigger }: DocumentListProps) {
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{doc.filename}</span>
{getStatusBadge(doc.processing_status)}
{doc.retrieval_mode === 'full_document' ? (
<Badge variant="outline" className="text-xs">Full Document</Badge>
) : (
<Badge variant="outline" className="text-xs">Chunked</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(doc.file_size_bytes)}</span>
{doc.processing_status === 'completed' && (
{doc.processing_status === 'completed' && doc.retrieval_mode !== 'full_document' && (
<span>{doc.total_chunks} chunks</span>
)}
<span>{formatDate(doc.created_at)}</span>

View file

@ -1,6 +1,6 @@
'use client';
import { Upload } from 'lucide-react';
import { FileText, Upload, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
@ -10,7 +10,9 @@ import {
} from '@/client/sdk.gen';
import type { DocumentUploadResponseSchema } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import logger from '@/lib/logger';
interface DocumentUploadProps {
@ -21,20 +23,20 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['.pdf', '.docx', '.doc', '.txt', '.json'];
export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [retrievalMode, setRetrievalMode] = useState<string>('full_document');
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): boolean => {
// Validate file type
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ACCEPTED_FILE_TYPES.includes(fileExtension)) {
toast.error(`Please select a supported file type: ${ACCEPTED_FILE_TYPES.join(', ')}`);
return false;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
toast.error('File size must be less than 5MB');
return false;
@ -43,27 +45,38 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
return true;
};
const uploadFile = async (file: File) => {
const handleFileSelected = (file: File) => {
if (!validateFile(file)) {
// Reset file input so the same file can be re-selected
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
return;
}
setSelectedFile(file);
};
const clearSelectedFile = () => {
setSelectedFile(null);
setRetrievalMode('full_document');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const uploadFile = async () => {
if (!selectedFile) return;
setUploading(true);
setUploadProgress(0);
try {
// Step 1: Request presigned upload URL
logger.info('Requesting presigned upload URL for:', file.name);
logger.info('Requesting presigned upload URL for:', selectedFile.name);
const uploadUrlResponse = await getUploadUrlApiV1KnowledgeBaseUploadUrlPost({
body: {
filename: file.name,
mime_type: file.type || 'application/octet-stream',
filename: selectedFile.name,
mime_type: selectedFile.type || 'application/octet-stream',
custom_metadata: {
original_filename: file.name,
original_filename: selectedFile.name,
uploaded_at: new Date().toISOString(),
},
},
@ -74,16 +87,13 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
}
const uploadData: DocumentUploadResponseSchema = uploadUrlResponse.data;
logger.info('Received presigned URL, uploading file...');
setUploadProgress(25);
// Step 2: Upload file directly to S3/MinIO using PUT
const uploadResponse = await fetch(uploadData.upload_url, {
method: 'PUT',
body: file,
body: selectedFile,
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Type': selectedFile.type || 'application/octet-stream',
},
});
@ -92,13 +102,12 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
}
setUploadProgress(75);
logger.info('File uploaded successfully, triggering processing...');
// Step 3: Trigger document processing
const processResponse = await processDocumentApiV1KnowledgeBaseProcessDocumentPost({
body: {
document_uuid: uploadData.document_uuid,
s3_key: uploadData.s3_key,
retrieval_mode: retrievalMode,
},
});
@ -107,9 +116,8 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
}
setUploadProgress(100);
logger.info('Document processing triggered successfully');
toast.success(`File uploaded: ${file.name}. Processing started.`);
toast.success(`File uploaded: ${selectedFile.name}. Processing started.`);
clearSelectedFile();
onUploadSuccess();
} catch (error) {
logger.error('Error uploading document:', error);
@ -117,17 +125,13 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
} finally {
setUploading(false);
setUploadProgress(0);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
await uploadFile(file);
handleFileSelected(file);
}
};
@ -141,14 +145,14 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
}
};
const handleDrop = async (e: React.DragEvent) => {
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
await uploadFile(file);
handleFileSelected(file);
}
};
@ -156,6 +160,69 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
fileInputRef.current?.click();
};
// Step 2: File selected — show retrieval mode choice
if (selectedFile && !uploading) {
return (
<div className="space-y-4">
{/* Selected file info */}
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/30">
<FileText className="w-8 h-8 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
<Button variant="ghost" size="icon" onClick={clearSelectedFile}>
<X className="w-4 h-4" />
</Button>
</div>
{/* Retrieval mode selection */}
<div className="space-y-3">
<Label className="text-sm font-medium">How should the agent use this document?</Label>
<RadioGroup value={retrievalMode} onValueChange={setRetrievalMode}>
<label
htmlFor="full_document"
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
retrievalMode === 'full_document' ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<RadioGroupItem value="full_document" id="full_document" className="mt-0.5" />
<div>
<p className="font-medium text-sm">Full Document</p>
<p className="text-xs text-muted-foreground">
The entire document is provided to the agent on each retrieval.
Best for menus, price lists, FAQs, and other small reference documents.
</p>
</div>
</label>
<label
htmlFor="chunked"
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
retrievalMode === 'chunked' ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<RadioGroupItem value="chunked" id="chunked" className="mt-0.5" />
<div>
<p className="font-medium text-sm">Chunked Search</p>
<p className="text-xs text-muted-foreground">
The document is split into chunks and the most relevant ones are retrieved.
Better for large documents like manuals or policies.
</p>
</div>
</label>
</RadioGroup>
</div>
{/* Upload button */}
<Button onClick={uploadFile} className="w-full">
Upload & Process
</Button>
</div>
);
}
return (
<div className="space-y-4">
<input
@ -204,16 +271,17 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
)}
{/* Manual Upload Button */}
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={handleButtonClick}
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Choose File'}
</Button>
</div>
{!uploading && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={handleButtonClick}
>
Choose File
</Button>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -42,7 +43,10 @@ export default function FilesPage() {
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Knowledge Base Files</h1>
<p className="text-muted-foreground">
Upload and manage documents for your voice agents to reference.
Upload and manage documents for your voice agents to reference.{" "}
<a href="https://docs.dograh.com/voice-agent/knowledge-base" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>

View file

@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
createRecordingsApiV1WorkflowRecordingsPost,
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
getSignedUrlApiV1S3SignedUrlGet,
getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost,
listRecordingsApiV1WorkflowRecordingsGet,
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
@ -30,6 +29,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
interface RecordingsDialogProps {
open: boolean;
@ -74,8 +74,7 @@ export const RecordingsDialog = ({
const [recordingStep, setRecordingStep] = useState<RecordingStep>("idle");
const [recordingFilename, setRecordingFilename] = useState("");
const [recordingDuration, setRecordingDuration] = useState(0);
const [playingId, setPlayingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { playingId, toggle: togglePlayback, stop: stopPlayback } = useAudioPlayback();
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -128,13 +127,6 @@ export const RecordingsDialog = ({
setRecordingDuration(0);
}, []);
const stopPlayback = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingId(null);
}, []);
useEffect(() => {
if (open) {
@ -363,27 +355,8 @@ export const RecordingsDialog = ({
};
const handlePlay = async (rec: RecordingResponseSchema) => {
if (playingId === rec.recording_id) {
stopPlayback();
return;
}
stopPlayback();
try {
const result = await getSignedUrlApiV1S3SignedUrlGet({
query: {
key: rec.storage_key,
storage_backend: rec.storage_backend,
},
});
if (!result.data?.url) {
setError("Failed to get audio URL");
return;
}
const audio = new Audio(result.data.url);
audio.onended = () => setPlayingId(null);
audioRef.current = audio;
setPlayingId(rec.recording_id);
await audio.play();
await togglePlayback(rec.recording_id, rec.storage_key, rec.storage_backend);
} catch {
setError("Failed to play recording");
}

View file

@ -1,22 +1,27 @@
'use client';
import { Check, Copy, ExternalLink, FileText, Video } from 'lucide-react';
import { Check, Copy, ExternalLink, FileText, LoaderCircle, Phone, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
import { RealtimeFeedback, WorkflowRunLogs } from '@/app/workflow/[workflowId]/run/[runId]/components/RealtimeFeedback';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet,
} from '@/client/sdk.gen';
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { getRandomId } from '@/lib/utils';
interface WorkflowRunResponse {
is_completed: boolean;
@ -72,7 +77,9 @@ function ContextDisplay({ title, context }: { title: string; context: Record<str
export default function WorkflowRunPage() {
const params = useParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [startingCall, setStartingCall] = useState(false);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
@ -120,6 +127,24 @@ export default function WorkflowRunPage() {
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const handleTestAgain = async () => {
if (startingCall) return;
setStartingCall(true);
try {
const workflowId = Number(params.workflowId);
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: { mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, name: workflowRunName },
});
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} finally {
setStartingCall(false);
}
};
let returnValue = null;
if (isLoading) {
@ -160,22 +185,37 @@ export default function WorkflowRunPage() {
</svg>
</div>
</div>
<Link href={`/workflow/${params.workflowId}`}>
<div className="flex items-center gap-2">
<Button
ref={customizeButtonRef}
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
onClick={() => {
if (!hasSeenTooltip('customize_workflow')) {
markTooltipSeen('customize_workflow');
}
}}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Customize Agent
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
</Link>
<Link href={`/workflow/${params.workflowId}`}>
<Button
ref={customizeButtonRef}
className="gap-2"
onClick={() => {
if (!hasSeenTooltip('customize_workflow')) {
markTooltipSeen('customize_workflow');
}
}}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Customize Agent
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>

View file

@ -1,10 +1,10 @@
"use client";
import { ArrowLeft, BookA, Brain, ExternalLink, Mic, PhoneOff, Rocket, Settings, Trash2Icon, Variable } from "lucide-react";
import { ArrowLeft, BookA, Brain, ExternalLink, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import { getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
@ -80,13 +81,17 @@ const NAV_ITEMS = [
// Section: General
// ---------------------------------------------------------------------------
const MAX_AMBIENT_NOISE_FILE_SIZE = 10 * 1024 * 1024; // 10MB
function GeneralSection({
workflowConfigurations,
workflowName,
workflowId,
onSave,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
workflowId: number;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const [name, setName] = useState(workflowName);
@ -103,6 +108,68 @@ function GeneralSection({
workflowConfigurations.context_compaction_enabled ?? false,
);
const [isSaving, setIsSaving] = useState(false);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [audioUploadError, setAudioUploadError] = useState<string | null>(null);
const ambientFileInputRef = useRef<HTMLInputElement>(null);
const { playingId, toggle: togglePlayback } = useAudioPlayback();
const handleAmbientFileUpload = async (file: File) => {
if (file.size > MAX_AMBIENT_NOISE_FILE_SIZE) {
setAudioUploadError(`File too large (${(file.size / (1024 * 1024)).toFixed(1)}MB). Maximum is 10MB.`);
return;
}
setIsUploadingAudio(true);
setAudioUploadError(null);
try {
// 1. Get presigned upload URL
const res = await getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost({
body: {
workflow_id: Number(workflowId),
filename: file.name,
mime_type: file.type || "audio/wav",
file_size: file.size,
},
});
if (res.error || !res.data?.upload_url) {
throw new Error("Failed to get upload URL");
}
const data = res.data;
// 2. Upload file to storage
const uploadRes = await fetch(data.upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type || "audio/wav" },
});
if (!uploadRes.ok) {
throw new Error("File upload failed");
}
// 3. Update config with storage reference
setAmbientNoiseConfig((prev) => ({
...prev,
storage_key: data.storage_key,
storage_backend: data.storage_backend,
original_filename: file.name,
}));
} catch (err) {
setAudioUploadError(err instanceof Error ? err.message : "Upload failed");
} finally {
setIsUploadingAudio(false);
if (ambientFileInputRef.current) ambientFileInputRef.current.value = "";
}
};
const handleRemoveCustomAudio = () => {
setAmbientNoiseConfig((prev) => ({
enabled: prev.enabled,
volume: prev.volume,
}));
};
const handleSave = async () => {
setIsSaving(true);
@ -156,7 +223,7 @@ function GeneralSection({
<div>
<h3 className="text-sm font-medium">Ambient Noise</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Add background office ambient noise to make the conversation sound more natural.
Add background ambient noise to make the conversation sound more natural.
</p>
</div>
<div className="flex items-center justify-between">
@ -170,20 +237,108 @@ function GeneralSection({
/>
</div>
{ambientNoiseConfig.enabled && (
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">Volume</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value }));
}}
/>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">Volume</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value }));
}}
/>
</div>
{/* Custom Audio File */}
<div className="space-y-2">
<Label className="text-xs">Custom Audio File</Label>
<p className="text-xs text-muted-foreground">
Upload your own audio file or use the default office ambience.
</p>
{ambientNoiseConfig.storage_key ? (
<div className="flex items-center gap-2 rounded-md border p-2 bg-muted/10">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate flex-1">
{ambientNoiseConfig.original_filename || "Custom audio"}
</code>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
onClick={async () => {
try {
await togglePlayback(
"ambient-noise",
ambientNoiseConfig.storage_key!,
ambientNoiseConfig.storage_backend,
);
} catch {
setAudioUploadError("Failed to play audio");
}
}}
>
{playingId === "ambient-noise" ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
onClick={handleRemoveCustomAudio}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<div>
<input
ref={ambientFileInputRef}
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAmbientFileUpload(file);
}}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
className="text-sm font-normal"
onClick={() => ambientFileInputRef.current?.click()}
disabled={isUploadingAudio}
>
{isUploadingAudio ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploadingAudio ? "Uploading..." : "Upload audio file (max 10MB)"}
</Button>
</div>
)}
{audioUploadError && (
<p className="text-xs text-destructive">{audioUploadError}</p>
)}
{!ambientNoiseConfig.storage_key && (
<p className="text-xs text-muted-foreground italic">
Using default office ambience
</p>
)}
</div>
</div>
)}
</div>
@ -786,6 +941,7 @@ function WorkflowSettingsContent({
<GeneralSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName || workflow.name}
workflowId={workflowId}
onSave={saveWorkflowConfigurations}
/>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -168,6 +168,48 @@ export type AccessTokenResponse = {
connection_id: string;
};
/**
* AmbientNoiseUploadRequest
*/
export type AmbientNoiseUploadRequest = {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Filename
*/
filename: string;
/**
* Mime Type
*/
mime_type?: string;
/**
* File Size
*
* Max 10MB
*/
file_size: number;
};
/**
* AmbientNoiseUploadResponse
*/
export type AmbientNoiseUploadResponse = {
/**
* Upload Url
*/
upload_url: string;
/**
* Storage Key
*/
storage_key: string;
/**
* Storage Backend
*/
storage_backend: string;
};
/**
* AuthResponse
*/
@ -1295,6 +1337,10 @@ export type DocumentResponseSchema = {
* Total Chunks
*/
total_chunks: number;
/**
* Retrieval Mode
*/
retrieval_mode?: string;
/**
* Custom Metadata
*/
@ -2036,6 +2082,12 @@ export type ProcessDocumentRequestSchema = {
* S3 key of the uploaded file
*/
s3_key: string;
/**
* Retrieval Mode
*
* Retrieval mode: 'chunked' for vector search or 'full_document' for full text retrieval
*/
retrieval_mode?: string;
};
/**
@ -5175,6 +5227,45 @@ export type DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse
export type DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse = DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses[keyof DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses];
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostData = {
body: AmbientNoiseUploadRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/workflow/ambient-noise/upload-url';
};
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostError = GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors[keyof GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors];
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses = {
/**
* Successful Response
*/
200: AmbientNoiseUploadResponse;
};
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponse = GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses[keyof GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses];
export type GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData = {
body?: never;
path?: never;

View file

@ -123,7 +123,7 @@ export const DocumentSelector = ({
{doc.filename}
</div>
<div className="text-xs text-muted-foreground">
{formatFileSize(doc.file_size_bytes)} {doc.total_chunks} chunks
{formatFileSize(doc.file_size_bytes)} {doc.retrieval_mode === 'full_document' ? 'Full Document' : `${doc.total_chunks} chunks`}
</div>
</div>
</label>

View file

@ -0,0 +1,57 @@
import { useCallback, useRef, useState } from "react";
import { getSignedUrlApiV1S3SignedUrlGet } from "@/client/sdk.gen";
/**
* Hook for playing audio files stored in S3/MinIO via signed URLs.
*
* Returns the currently-playing ID (or null), a toggle function, and a stop function.
*/
export function useAudioPlayback() {
const [playingId, setPlayingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const stop = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingId(null);
}, []);
const toggle = useCallback(
async (id: string, storageKey: string, storageBackend?: string) => {
// If already playing this id, stop it
if (audioRef.current && playingId === id) {
stop();
return;
}
// Stop any previous playback
stop();
const result = await getSignedUrlApiV1S3SignedUrlGet({
query: {
key: storageKey,
storage_backend: storageBackend,
},
});
if (!result.data?.url) {
throw new Error("Failed to get audio URL");
}
const audio = new Audio(result.data.url);
audio.onended = () => {
audioRef.current = null;
setPlayingId(null);
};
audioRef.current = audio;
setPlayingId(id);
await audio.play();
},
[playingId, stop],
);
return { playingId, toggle, stop } as const;
}

View file

@ -8,6 +8,9 @@ export interface VADConfiguration {
export interface AmbientNoiseConfiguration {
enabled: boolean;
volume: number;
storage_key?: string;
storage_backend?: string;
original_filename?: string;
}
export type TurnStopStrategy = 'transcription' | 'turn_analyzer';