mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: add full document mode in knowledge base
This commit is contained in:
parent
c085398933
commit
87c8c5e2c8
26 changed files with 1144 additions and 351 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
57
ui/src/hooks/useAudioPlayback.ts
Normal file
57
ui/src/hooks/useAudioPlayback.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue