mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: knowledge base functionality for the voice agent (#120)
* feat: upload file and store embedding * feat: add documents in nodes * feat: add openai embedding service
This commit is contained in:
parent
e2fa4bbb98
commit
ef5b9e40a9
52 changed files with 4551 additions and 114 deletions
249
ui/src/app/files/DocumentList.tsx
Normal file
249
ui/src/app/files/DocumentList.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
'use client';
|
||||
|
||||
import { FileText, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
deleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDelete,
|
||||
listDocumentsApiV1KnowledgeBaseDocumentsGet,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { DocumentResponseSchema } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface DocumentListProps {
|
||||
accessToken: string;
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
export default function DocumentList({ accessToken, refreshTrigger }: DocumentListProps) {
|
||||
const [documents, setDocuments] = useState<DocumentResponseSchema[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await listDocumentsApiV1KnowledgeBaseDocumentsGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
query: {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
throw new Error('Failed to fetch documents');
|
||||
}
|
||||
|
||||
setDocuments(response.data.documents);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch documents');
|
||||
logger.error('Error fetching documents:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
// Fetch documents on mount and when refreshTrigger changes
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments, refreshTrigger]);
|
||||
|
||||
// Poll for documents that are processing
|
||||
useEffect(() => {
|
||||
const processingDocs = documents.filter(
|
||||
(doc) => doc.processing_status === 'processing' || doc.processing_status === 'pending'
|
||||
);
|
||||
|
||||
if (processingDocs.length === 0) return;
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
logger.info(`Polling for ${processingDocs.length} processing documents...`);
|
||||
fetchDocuments();
|
||||
}, 5000); // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [documents, fetchDocuments]);
|
||||
|
||||
const handleDelete = async (documentUuid: string, filename: string) => {
|
||||
if (!confirm(`Are you sure you want to delete "${filename}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDelete({
|
||||
path: {
|
||||
document_uuid: documentUuid,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('Failed to delete document');
|
||||
}
|
||||
|
||||
toast.success(`Deleted "${filename}"`);
|
||||
fetchDocuments();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete document');
|
||||
logger.error('Error deleting document:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500">Completed</Badge>;
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Processing
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const filteredDocuments = documents.filter((doc) =>
|
||||
doc.filename.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading && documents.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and Refresh */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search documents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={fetchDocuments}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No documents match your search'
|
||||
: 'No documents uploaded yet'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.document_uuid}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium truncate">{doc.filename}</span>
|
||||
{getStatusBadge(doc.processing_status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(doc.file_size_bytes)}</span>
|
||||
{doc.processing_status === 'completed' && (
|
||||
<span>{doc.total_chunks} chunks</span>
|
||||
)}
|
||||
<span>{formatDate(doc.created_at)}</span>
|
||||
</div>
|
||||
{doc.processing_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
Error: {doc.processing_error}
|
||||
</p>
|
||||
)}
|
||||
{doc.docling_metadata &&
|
||||
typeof doc.docling_metadata === 'object' &&
|
||||
'duplicate_of' in doc.docling_metadata && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Duplicate of another document
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(doc.document_uuid, doc.filename)}
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
ui/src/app/files/DocumentUpload.tsx
Normal file
220
ui/src/app/files/DocumentUpload.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
'use client';
|
||||
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
getUploadUrlApiV1KnowledgeBaseUploadUrlPost,
|
||||
processDocumentApiV1KnowledgeBaseProcessDocumentPost,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { DocumentUploadResponseSchema } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface DocumentUploadProps {
|
||||
accessToken: string;
|
||||
onUploadSuccess: () => void;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const ACCEPTED_FILE_TYPES = ['.pdf', '.docx', '.doc', '.txt'];
|
||||
|
||||
export default function DocumentUpload({ accessToken, onUploadSuccess }: DocumentUploadProps) {
|
||||
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 100MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
if (!validateFile(file)) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Step 1: Request presigned upload URL
|
||||
logger.info('Requesting presigned upload URL for:', file.name);
|
||||
const uploadUrlResponse = await getUploadUrlApiV1KnowledgeBaseUploadUrlPost({
|
||||
body: {
|
||||
filename: file.name,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
custom_metadata: {
|
||||
original_filename: file.name,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (uploadUrlResponse.error || !uploadUrlResponse.data) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file to storage');
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (processResponse.error) {
|
||||
throw new Error('Failed to trigger processing');
|
||||
}
|
||||
|
||||
setUploadProgress(100);
|
||||
logger.info('Document processing triggered successfully');
|
||||
|
||||
toast.success(`File uploaded: ${file.name}. Processing started.`);
|
||||
onUploadSuccess();
|
||||
} catch (error) {
|
||||
logger.error('Error uploading document:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload document');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
{/* Drag and Drop Area */}
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||
${dragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
||||
${uploading ? 'opacity-50 pointer-events-none' : 'cursor-pointer hover:border-primary hover:bg-muted/50'}
|
||||
`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium mb-2">
|
||||
{uploading ? 'Uploading...' : 'Drop your document here'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: {ACCEPTED_FILE_TYPES.join(', ')} (Max 100MB)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Upload Button */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleButtonClick}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Choose File'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
ui/src/app/files/page.tsx
Normal file
104
ui/src/app/files/page.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import DocumentList from "./DocumentList";
|
||||
import DocumentUpload from "./DocumentUpload";
|
||||
|
||||
export default function FilesPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [accessToken, setAccessToken] = useState<string>('');
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Get access token
|
||||
const fetchAccessToken = useCallback(async () => {
|
||||
if (user) {
|
||||
const token = await getAccessToken();
|
||||
setAccessToken(token);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccessToken();
|
||||
}, [fetchAccessToken]);
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
// Trigger refresh of document list
|
||||
setRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
if (loading || !user || !accessToken) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Files</TabsTrigger>
|
||||
<TabsTrigger value="upload">Upload New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Documents</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage your uploaded documents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DocumentList
|
||||
accessToken={accessToken}
|
||||
refreshTrigger={refreshKey}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Document</CardTitle>
|
||||
<CardDescription>
|
||||
Upload a PDF or document file to add to your knowledge base
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DocumentUpload
|
||||
accessToken={accessToken}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import {
|
|||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import type { DocumentResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
|
@ -63,6 +65,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
|
||||
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
|
|
@ -95,6 +99,36 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
getAccessToken
|
||||
});
|
||||
|
||||
// Fetch documents and tools once for the entire workflow
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Fetch documents
|
||||
const documentsResponse = await listDocumentsApiV1KnowledgeBaseDocumentsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
query: { limit: 100 },
|
||||
});
|
||||
if (documentsResponse.data) {
|
||||
setDocuments(documentsResponse.data.documents);
|
||||
}
|
||||
|
||||
// Fetch tools
|
||||
const toolsResponse = await listToolsApiV1ToolsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (toolsResponse.data) {
|
||||
setTools(toolsResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch documents and tools:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [getAccessToken]);
|
||||
|
||||
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
animated: true,
|
||||
|
|
@ -102,7 +136,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
}), []);
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]);
|
||||
const workflowContextValue = useMemo(() => ({
|
||||
saveWorkflow,
|
||||
documents,
|
||||
tools
|
||||
}), [saveWorkflow, documents, tools]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { DocumentResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
|
||||
interface WorkflowContextType {
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
documents?: DocumentResponseSchema[];
|
||||
tools?: ToolResponse[];
|
||||
}
|
||||
|
||||
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
|
||||
|
|
@ -15,3 +19,8 @@ export const useWorkflow = () => {
|
|||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Optional hook that doesn't throw if context is not available
|
||||
export const useWorkflowOptional = () => {
|
||||
return useContext(WorkflowContext);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
|
||||
import { createClientConfig } from '../lib/apiClient';
|
||||
import type { ClientOptions } from './types.gen';
|
||||
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
import { createClientConfig } from '../lib/apiClient';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
|
|
@ -17,4 +16,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
|
|||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'http://127.0.0.1:8000'
|
||||
})));
|
||||
})));
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export * from './sdk.gen';
|
||||
export * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -83,6 +83,54 @@ export type CampaignsResponse = {
|
|||
campaigns: Array<CampaignResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for a document chunk.
|
||||
*/
|
||||
export type ChunkResponseSchema = {
|
||||
id: number;
|
||||
document_id: number;
|
||||
chunk_text: string;
|
||||
contextualized_text: string | null;
|
||||
chunk_index: number;
|
||||
chunk_metadata: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
filename: string;
|
||||
document_uuid: string;
|
||||
similarity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for searching similar chunks.
|
||||
*/
|
||||
export type ChunkSearchRequestSchema = {
|
||||
/**
|
||||
* Search query text
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* Maximum number of results
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Filter by specific document UUIDs
|
||||
*/
|
||||
document_uuids?: Array<string> | null;
|
||||
/**
|
||||
* Minimum similarity threshold
|
||||
*/
|
||||
min_similarity?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for chunk search results.
|
||||
*/
|
||||
export type ChunkSearchResponseSchema = {
|
||||
chunks: Array<ChunkResponseSchema>;
|
||||
query: string;
|
||||
total_results: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for Cloudonix configuration.
|
||||
*/
|
||||
|
|
@ -303,11 +351,91 @@ export type DefaultConfigurationsResponse = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
embeddings: {
|
||||
[key: string]: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
default_providers: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for list of documents.
|
||||
*/
|
||||
export type DocumentListResponseSchema = {
|
||||
documents: Array<DocumentResponseSchema>;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for document metadata.
|
||||
*/
|
||||
export type DocumentResponseSchema = {
|
||||
id: number;
|
||||
document_uuid: string;
|
||||
filename: string;
|
||||
file_size_bytes: number;
|
||||
file_hash: string;
|
||||
mime_type: string;
|
||||
processing_status: string;
|
||||
processing_error?: string | null;
|
||||
total_chunks: number;
|
||||
custom_metadata: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
docling_metadata: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
source_url?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
organization_id: number;
|
||||
created_by: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for initiating document upload.
|
||||
*/
|
||||
export type DocumentUploadRequestSchema = {
|
||||
/**
|
||||
* Name of the file to upload
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* MIME type of the file
|
||||
*/
|
||||
mime_type: string;
|
||||
/**
|
||||
* Optional custom metadata
|
||||
*/
|
||||
custom_metadata?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema containing upload URL and document metadata.
|
||||
*/
|
||||
export type DocumentUploadResponseSchema = {
|
||||
/**
|
||||
* Signed URL for uploading the file
|
||||
*/
|
||||
upload_url: string;
|
||||
/**
|
||||
* Unique identifier for the document
|
||||
*/
|
||||
document_uuid: string;
|
||||
/**
|
||||
* S3 key where file should be uploaded
|
||||
*/
|
||||
s3_key: string;
|
||||
};
|
||||
|
||||
export type DuplicateTemplateRequest = {
|
||||
template_id: number;
|
||||
workflow_name: string;
|
||||
|
|
@ -537,6 +665,24 @@ export type PresignedUploadUrlResponse = {
|
|||
expires_in: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for triggering document processing.
|
||||
*/
|
||||
export type ProcessDocumentRequestSchema = {
|
||||
/**
|
||||
* Document UUID to process
|
||||
*/
|
||||
document_uuid: string;
|
||||
/**
|
||||
* S3 key of the uploaded file
|
||||
*/
|
||||
s3_key: string;
|
||||
/**
|
||||
* Embedding service to use for processing. Options: 'openai' (default, 1536-dim, requires API key) or 'sentence_transformer' (free, 384-dim)
|
||||
*/
|
||||
embedding_service?: 'sentence_transformer' | 'openai';
|
||||
};
|
||||
|
||||
export type S3SignedUrlResponse = {
|
||||
url: string;
|
||||
expires_in: number;
|
||||
|
|
@ -787,6 +933,9 @@ export type UserConfigurationRequestResponseSchema = {
|
|||
stt?: {
|
||||
[key: string]: string | number;
|
||||
} | null;
|
||||
embeddings?: {
|
||||
[key: string]: string | number;
|
||||
} | null;
|
||||
test_phone_number?: string | null;
|
||||
timezone?: string | null;
|
||||
organization_pricing?: {
|
||||
|
|
@ -4126,6 +4275,213 @@ export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostRespons
|
|||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses];
|
||||
|
||||
export type GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData = {
|
||||
body: DocumentUploadRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/knowledge-base/upload-url';
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError = GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors[keyof GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors];
|
||||
|
||||
export type GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: DocumentUploadResponseSchema;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse = GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses[keyof GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses];
|
||||
|
||||
export type ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData = {
|
||||
body: ProcessDocumentRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/knowledge-base/process-document';
|
||||
};
|
||||
|
||||
export type ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError = ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors[keyof ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors];
|
||||
|
||||
export type ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: DocumentResponseSchema;
|
||||
};
|
||||
|
||||
export type ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse = ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses[keyof ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses];
|
||||
|
||||
export type ListDocumentsApiV1KnowledgeBaseDocumentsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Filter by processing status
|
||||
*/
|
||||
status?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
url: '/api/v1/knowledge-base/documents';
|
||||
};
|
||||
|
||||
export type ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ListDocumentsApiV1KnowledgeBaseDocumentsGetError = ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors[keyof ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors];
|
||||
|
||||
export type ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: DocumentListResponseSchema;
|
||||
};
|
||||
|
||||
export type ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse = ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses[keyof ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses];
|
||||
|
||||
export type DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
document_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/knowledge-base/documents/{document_uuid}';
|
||||
};
|
||||
|
||||
export type DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError = DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors[keyof DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors];
|
||||
|
||||
export type DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
document_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/knowledge-base/documents/{document_uuid}';
|
||||
};
|
||||
|
||||
export type GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError = GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors[keyof GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors];
|
||||
|
||||
export type GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: DocumentResponseSchema;
|
||||
};
|
||||
|
||||
export type GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse = GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses[keyof GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses];
|
||||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostData = {
|
||||
body: ChunkSearchRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/knowledge-base/search';
|
||||
};
|
||||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostError = SearchChunksApiV1KnowledgeBaseSearchPostErrors[keyof SearchChunksApiV1KnowledgeBaseSearchPostErrors];
|
||||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ChunkSearchResponseSchema;
|
||||
};
|
||||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses];
|
||||
|
||||
export type HealthApiV1HealthGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -4149,4 +4505,4 @@ export type HealthApiV1HealthGetResponses = {
|
|||
|
||||
export type ClientOptions = {
|
||||
baseUrl: 'http://127.0.0.1:8000' | (string & {});
|
||||
};
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { VoiceSelector } from "@/components/VoiceSelector";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt";
|
||||
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings";
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
|
|
@ -41,6 +41,7 @@ const TAB_CONFIG: { key: ServiceSegment; label: string }[] = [
|
|||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
{ key: "stt", label: "Transcriber" },
|
||||
{ key: "embeddings", label: "Embedding" },
|
||||
];
|
||||
|
||||
// Display names for language codes (Deepgram + Sarvam)
|
||||
|
|
@ -109,12 +110,14 @@ export default function ServiceConfiguration() {
|
|||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
tts: {},
|
||||
stt: {}
|
||||
stt: {},
|
||||
embeddings: {}
|
||||
});
|
||||
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
|
||||
llm: "",
|
||||
tts: "",
|
||||
stt: ""
|
||||
stt: "",
|
||||
embeddings: ""
|
||||
});
|
||||
const [isManualModelInput, setIsManualModelInput] = useState(false);
|
||||
const [hasCheckedManualMode, setHasCheckedManualMode] = useState(false);
|
||||
|
|
@ -136,7 +139,8 @@ export default function ServiceConfiguration() {
|
|||
setSchemas({
|
||||
llm: response.data.llm as Record<string, ProviderSchema>,
|
||||
tts: response.data.tts as Record<string, ProviderSchema>,
|
||||
stt: response.data.stt as Record<string, ProviderSchema>
|
||||
stt: response.data.stt as Record<string, ProviderSchema>,
|
||||
embeddings: response.data.embeddings as Record<string, ProviderSchema>
|
||||
});
|
||||
} else {
|
||||
console.error("Failed to fetch configurations");
|
||||
|
|
@ -147,7 +151,8 @@ export default function ServiceConfiguration() {
|
|||
const selectedProviders: Record<ServiceSegment, string> = {
|
||||
llm: response.data.default_providers.llm,
|
||||
tts: response.data.default_providers.tts,
|
||||
stt: response.data.default_providers.stt
|
||||
stt: response.data.default_providers.stt,
|
||||
embeddings: response.data.default_providers.embeddings
|
||||
};
|
||||
|
||||
const setServicePropertyValues = (service: ServiceSegment) => {
|
||||
|
|
@ -173,6 +178,7 @@ export default function ServiceConfiguration() {
|
|||
setServicePropertyValues("llm");
|
||||
setServicePropertyValues("tts");
|
||||
setServicePropertyValues("stt");
|
||||
setServicePropertyValues("embeddings");
|
||||
|
||||
// IMPORTANT: Reset form values BEFORE changing providers
|
||||
// Otherwise, Radix Select sees old values that don't match new provider's enum
|
||||
|
|
@ -246,7 +252,7 @@ export default function ServiceConfiguration() {
|
|||
setApiError(null);
|
||||
setIsSaving(true);
|
||||
|
||||
const userConfig = {
|
||||
const userConfig: Record<ServiceSegment, Record<string, string | number>> = {
|
||||
llm: {
|
||||
provider: serviceProviders.llm,
|
||||
api_key: data.llm_api_key as string,
|
||||
|
|
@ -259,6 +265,11 @@ export default function ServiceConfiguration() {
|
|||
stt: {
|
||||
provider: serviceProviders.stt,
|
||||
api_key: data.stt_api_key as string
|
||||
},
|
||||
embeddings: {
|
||||
provider: serviceProviders.embeddings,
|
||||
api_key: data.embeddings_api_key as string,
|
||||
model: data.embeddings_model as string
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -273,12 +284,25 @@ export default function ServiceConfiguration() {
|
|||
}
|
||||
});
|
||||
|
||||
// Build save config - only include embeddings if api_key is provided
|
||||
const saveConfig: {
|
||||
llm: Record<string, string | number>;
|
||||
tts: Record<string, string | number>;
|
||||
stt: Record<string, string | number>;
|
||||
embeddings?: Record<string, string | number>;
|
||||
} = {
|
||||
llm: userConfig.llm,
|
||||
tts: userConfig.tts,
|
||||
stt: userConfig.stt
|
||||
};
|
||||
|
||||
// Only include embeddings if user has configured it (has api_key)
|
||||
if (userConfig.embeddings.api_key) {
|
||||
saveConfig.embeddings = userConfig.embeddings;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveUserConfig({
|
||||
llm: userConfig.llm,
|
||||
tts: userConfig.tts,
|
||||
stt: userConfig.stt
|
||||
});
|
||||
await saveUserConfig(saveConfig);
|
||||
setApiError(null);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -543,7 +567,7 @@ export default function ServiceConfiguration() {
|
|||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs defaultValue="llm" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
{TAB_CONFIG.map(({ key, label }) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
|
|
|
|||
65
ui/src/components/flow/DocumentBadges.tsx
Normal file
65
ui/src/components/flow/DocumentBadges.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { DocumentResponseSchema } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface DocumentBadgesProps {
|
||||
documentUuids: string[];
|
||||
onStaleUuidsDetected?: (staleUuids: string[]) => void;
|
||||
}
|
||||
|
||||
export const DocumentBadges = ({ documentUuids, onStaleUuidsDetected }: DocumentBadgesProps) => {
|
||||
const { documents } = useWorkflow();
|
||||
const [documentNames, setDocumentNames] = useState<Record<string, string>>({});
|
||||
|
||||
const processDocuments = useCallback((docs: DocumentResponseSchema[]) => {
|
||||
const nameMap: Record<string, string> = {};
|
||||
const validUuids = new Set<string>();
|
||||
|
||||
docs
|
||||
.filter((doc) => documentUuids.includes(doc.document_uuid))
|
||||
.forEach((doc) => {
|
||||
nameMap[doc.document_uuid] = doc.filename;
|
||||
validUuids.add(doc.document_uuid);
|
||||
});
|
||||
setDocumentNames(nameMap);
|
||||
|
||||
// Detect stale UUIDs - this only runs when we have loaded data (not undefined)
|
||||
if (onStaleUuidsDetected) {
|
||||
const staleUuids = documentUuids.filter(uuid => !validUuids.has(uuid));
|
||||
if (staleUuids.length > 0) {
|
||||
onStaleUuidsDetected(staleUuids);
|
||||
}
|
||||
}
|
||||
}, [documentUuids, onStaleUuidsDetected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentUuids.length > 0 && documents !== undefined) {
|
||||
processDocuments(documents);
|
||||
} else if (documentUuids.length === 0) {
|
||||
setDocumentNames({});
|
||||
}
|
||||
}, [documentUuids, documents, processDocuments]);
|
||||
|
||||
if (documentUuids.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// Show loading while data hasn't loaded yet
|
||||
if (documents === undefined) {
|
||||
return <Badge variant="outline">Loading...</Badge>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{documentUuids.map((uuid) => (
|
||||
<Badge key={uuid} variant="outline">
|
||||
{documentNames[uuid] || uuid}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
137
ui/src/components/flow/DocumentSelector.tsx
Normal file
137
ui/src/components/flow/DocumentSelector.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { DocumentResponseSchema } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface DocumentSelectorProps {
|
||||
value: string[];
|
||||
onChange: (uuids: string[]) => void;
|
||||
documents: DocumentResponseSchema[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export const DocumentSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
documents,
|
||||
disabled = false,
|
||||
label = "Knowledge Base Documents",
|
||||
description = "Select documents that the agent can reference during conversations.",
|
||||
showLabel = true,
|
||||
}: DocumentSelectorProps) => {
|
||||
// Only show completed documents
|
||||
const completedDocuments = useMemo(
|
||||
() => documents.filter((doc) => doc.processing_status === "completed"),
|
||||
[documents]
|
||||
);
|
||||
|
||||
const handleToggle = (documentUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...value, documentUuid]);
|
||||
} else {
|
||||
onChange(value.filter((uuid) => uuid !== documentUuid));
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
if (completedDocuments.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showLabel && (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">{description}</Label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
No documents available. Upload documents to the knowledge base first.
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Link href="/files">
|
||||
<Button variant="outline" size="sm">
|
||||
Upload Documents
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showLabel && (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">{description}</Label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="border rounded-md max-h-[300px] overflow-y-auto">
|
||||
<div className="divide-y">
|
||||
{completedDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.document_uuid}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={`doc-${doc.document_uuid}`}
|
||||
checked={value.includes(doc.document_uuid)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(doc.document_uuid, checked as boolean)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label
|
||||
htmlFor={`doc-${doc.document_uuid}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{doc.filename}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatFileSize(doc.file_size_bytes)} • {doc.total_chunks} chunks
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1">
|
||||
<span>
|
||||
{value.length} {value.length === 1 ? "document" : "documents"} selected
|
||||
</span>
|
||||
<Link href="/files" className="hover:underline">
|
||||
Manage Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,43 +2,43 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ToolBadgesProps {
|
||||
toolUuids: string[];
|
||||
onStaleUuidsDetected?: (staleUuids: string[]) => void;
|
||||
}
|
||||
|
||||
export function ToolBadges({ toolUuids }: ToolBadgesProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const [tools, setTools] = useState<ToolResponse[]>([]);
|
||||
export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps) {
|
||||
const { tools } = useWorkflow();
|
||||
const [selectedTools, setSelectedTools] = useState<ToolResponse[]>([]);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listToolsApiV1ToolsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.data) {
|
||||
setTools(response.data);
|
||||
const processTools = useCallback((toolsData: ToolResponse[]) => {
|
||||
const filtered = toolsData.filter(tool => toolUuids.includes(tool.tool_uuid));
|
||||
setSelectedTools(filtered);
|
||||
|
||||
// Detect stale UUIDs - this only runs when we have loaded data (not undefined)
|
||||
if (onStaleUuidsDetected) {
|
||||
const validUuids = new Set(toolsData.map(tool => tool.tool_uuid));
|
||||
const staleUuids = toolUuids.filter(uuid => !validUuids.has(uuid));
|
||||
if (staleUuids.length > 0) {
|
||||
onStaleUuidsDetected(staleUuids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tools:", error);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
}, [toolUuids, onStaleUuidsDetected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolUuids.length > 0) {
|
||||
fetchTools();
|
||||
if (toolUuids.length > 0 && tools !== undefined) {
|
||||
processTools(tools);
|
||||
} else if (toolUuids.length === 0) {
|
||||
setSelectedTools([]);
|
||||
}
|
||||
}, [toolUuids.length, fetchTools]);
|
||||
}, [toolUuids, tools, processTools]);
|
||||
|
||||
const selectedTools = tools.filter((tool) => toolUuids.includes(tool.tool_uuid));
|
||||
|
||||
if (selectedTools.length === 0 && toolUuids.length > 0) {
|
||||
// Still loading or tools not found
|
||||
// Show loading while data hasn't loaded yet
|
||||
if (tools === undefined && toolUuids.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Loader2 } from "lucide-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { renderToolIcon } from "@/app/tools/config";
|
||||
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ToolSelectorProps {
|
||||
value: string[];
|
||||
onChange: (uuids: string[]) => void;
|
||||
tools: ToolResponse[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
|
@ -24,43 +22,14 @@ interface ToolSelectorProps {
|
|||
export function ToolSelector({
|
||||
value,
|
||||
onChange,
|
||||
tools,
|
||||
disabled = false,
|
||||
label = "Tools",
|
||||
description = "Select tools that the agent can use during the conversation.",
|
||||
showLabel = true,
|
||||
}: ToolSelectorProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [tools, setTools] = useState<ToolResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await listToolsApiV1ToolsGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
query: { status: "active" },
|
||||
});
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch tools:", response.error);
|
||||
setTools([]);
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
setTools(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tools:", error);
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
// Filter to only show active tools
|
||||
const activeTools = tools.filter((tool) => tool.status === "active");
|
||||
|
||||
const handleToggle = (toolUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
|
|
@ -83,12 +52,7 @@ export function ToolSelector({
|
|||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 p-3 border rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Loading tools...</span>
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
{activeTools.length === 0 ? (
|
||||
<div className="p-4 border rounded-md text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No tools available.
|
||||
|
|
@ -102,7 +66,7 @@ export function ToolSelector({
|
|||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md divide-y">
|
||||
{tools.map((tool) => {
|
||||
{activeTools.map((tool) => {
|
||||
const isSelected = value.includes(tool.tool_uuid);
|
||||
return (
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { Edit, FileText, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { DocumentResponseSchema, ToolResponse } from "@/client/types.gen";
|
||||
import { DocumentBadges } from "@/components/flow/DocumentBadges";
|
||||
import { DocumentSelector } from "@/components/flow/DocumentSelector";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -34,6 +37,10 @@ interface AgentNodeEditFormProps {
|
|||
setAddGlobalPrompt: (value: boolean) => void;
|
||||
toolUuids: string[];
|
||||
setToolUuids: (value: string[]) => void;
|
||||
documentUuids: string[];
|
||||
setDocumentUuids: (value: string[]) => void;
|
||||
tools: ToolResponse[];
|
||||
documents: DocumentResponseSchema[];
|
||||
}
|
||||
|
||||
interface AgentNodeProps extends NodeProps {
|
||||
|
|
@ -42,7 +49,7 @@ interface AgentNodeProps extends NodeProps {
|
|||
|
||||
export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
||||
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { saveWorkflow, tools, documents } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt);
|
||||
|
|
@ -55,6 +62,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
|
||||
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
|
||||
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
|
||||
const [documentUuids, setDocumentUuids] = useState<string[]>(data.document_uuids ?? []);
|
||||
|
||||
// Compute if form has unsaved changes (only check prompt, name)
|
||||
const isDirty = useMemo(() => {
|
||||
|
|
@ -75,6 +83,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
extraction_variables: variables,
|
||||
add_global_prompt: addGlobalPrompt,
|
||||
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
|
||||
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
// Save the workflow after updating node data with a small delay to ensure state is updated
|
||||
|
|
@ -94,6 +103,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
setDocumentUuids(data.document_uuids ?? []);
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
|
@ -109,9 +119,34 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
setDocumentUuids(data.document_uuids ?? []);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
// Handle cleanup of stale document UUIDs
|
||||
const handleStaleDocuments = useCallback((staleUuids: string[]) => {
|
||||
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
|
||||
handleSaveNodeData({
|
||||
...data,
|
||||
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await saveWorkflow();
|
||||
}, 100);
|
||||
}, [data, handleSaveNodeData, saveWorkflow]);
|
||||
|
||||
// Handle cleanup of stale tool UUIDs
|
||||
const handleStaleTools = useCallback((staleUuids: string[]) => {
|
||||
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
|
||||
handleSaveNodeData({
|
||||
...data,
|
||||
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await saveWorkflow();
|
||||
}, 100);
|
||||
}, [data, handleSaveNodeData, saveWorkflow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
|
|
@ -136,7 +171,16 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
<Wrench className="h-3 w-3" />
|
||||
<span>Tools:</span>
|
||||
</div>
|
||||
<ToolBadges toolUuids={data.tool_uuids} />
|
||||
<ToolBadges toolUuids={data.tool_uuids} onStaleUuidsDetected={handleStaleTools} />
|
||||
</div>
|
||||
)}
|
||||
{data.document_uuids && data.document_uuids.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Documents:</span>
|
||||
</div>
|
||||
<DocumentBadges documentUuids={data.document_uuids} onStaleUuidsDetected={handleStaleDocuments} />
|
||||
</div>
|
||||
)}
|
||||
</NodeContent>
|
||||
|
|
@ -179,6 +223,10 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setAddGlobalPrompt={setAddGlobalPrompt}
|
||||
toolUuids={toolUuids}
|
||||
setToolUuids={setToolUuids}
|
||||
documentUuids={documentUuids}
|
||||
setDocumentUuids={setDocumentUuids}
|
||||
tools={tools ?? []}
|
||||
documents={documents ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -203,6 +251,10 @@ const AgentNodeEditForm = ({
|
|||
setAddGlobalPrompt,
|
||||
toolUuids,
|
||||
setToolUuids,
|
||||
documentUuids,
|
||||
setDocumentUuids,
|
||||
tools,
|
||||
documents,
|
||||
}: AgentNodeEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -343,9 +395,20 @@ const AgentNodeEditForm = ({
|
|||
<ToolSelector
|
||||
value={toolUuids}
|
||||
onChange={setToolUuids}
|
||||
tools={tools}
|
||||
description="Select tools that the agent can invoke during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="pt-4 border-t mt-4">
|
||||
<DocumentSelector
|
||||
value={documentUuids}
|
||||
onChange={setDocumentUuids}
|
||||
documents={documents}
|
||||
description="Select documents from the knowledge base that the agent can reference during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Play, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { Edit, FileText, Play, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { DocumentResponseSchema, ToolResponse } from "@/client/types.gen";
|
||||
import { DocumentBadges } from "@/components/flow/DocumentBadges";
|
||||
import { DocumentSelector } from "@/components/flow/DocumentSelector";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -41,6 +44,10 @@ interface StartCallEditFormProps {
|
|||
setVariables: (vars: ExtractionVariable[]) => void;
|
||||
toolUuids: string[];
|
||||
setToolUuids: (value: string[]) => void;
|
||||
documentUuids: string[];
|
||||
setDocumentUuids: (value: string[]) => void;
|
||||
tools: ToolResponse[];
|
||||
documents: DocumentResponseSchema[];
|
||||
}
|
||||
|
||||
interface StartCallNodeProps extends NodeProps {
|
||||
|
|
@ -52,7 +59,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
id,
|
||||
additionalData: { is_start: true }
|
||||
});
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { saveWorkflow, tools, documents } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt ?? "");
|
||||
|
|
@ -66,6 +73,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
|
||||
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
|
||||
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
|
||||
const [documentUuids, setDocumentUuids] = useState<string[]>(data.document_uuids ?? []);
|
||||
|
||||
// Compute if form has unsaved changes (only check prompt, name)
|
||||
const isDirty = useMemo(() => {
|
||||
|
|
@ -89,6 +97,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
extraction_prompt: extractionPrompt,
|
||||
extraction_variables: variables,
|
||||
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
|
||||
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
// Save the workflow after updating node data with a small delay to ensure state is updated
|
||||
|
|
@ -111,6 +120,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
setDocumentUuids(data.document_uuids ?? []);
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
|
@ -129,9 +139,34 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setToolUuids(data.tool_uuids ?? []);
|
||||
setDocumentUuids(data.document_uuids ?? []);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
// Handle cleanup of stale document UUIDs
|
||||
const handleStaleDocuments = useCallback((staleUuids: string[]) => {
|
||||
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
|
||||
handleSaveNodeData({
|
||||
...data,
|
||||
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await saveWorkflow();
|
||||
}, 100);
|
||||
}, [data, handleSaveNodeData, saveWorkflow]);
|
||||
|
||||
// Handle cleanup of stale tool UUIDs
|
||||
const handleStaleTools = useCallback((staleUuids: string[]) => {
|
||||
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
|
||||
handleSaveNodeData({
|
||||
...data,
|
||||
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await saveWorkflow();
|
||||
}, 100);
|
||||
}, [data, handleSaveNodeData, saveWorkflow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
|
|
@ -155,7 +190,16 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
<Wrench className="h-3 w-3" />
|
||||
<span>Tools:</span>
|
||||
</div>
|
||||
<ToolBadges toolUuids={data.tool_uuids} />
|
||||
<ToolBadges toolUuids={data.tool_uuids} onStaleUuidsDetected={handleStaleTools} />
|
||||
</div>
|
||||
)}
|
||||
{data.document_uuids && data.document_uuids.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Documents:</span>
|
||||
</div>
|
||||
<DocumentBadges documentUuids={data.document_uuids} onStaleUuidsDetected={handleStaleDocuments} />
|
||||
</div>
|
||||
)}
|
||||
</NodeContent>
|
||||
|
|
@ -199,6 +243,10 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setVariables={setVariables}
|
||||
toolUuids={toolUuids}
|
||||
setToolUuids={setToolUuids}
|
||||
documentUuids={documentUuids}
|
||||
setDocumentUuids={setDocumentUuids}
|
||||
tools={tools ?? []}
|
||||
documents={documents ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -229,6 +277,10 @@ const StartCallEditForm = ({
|
|||
setVariables,
|
||||
toolUuids,
|
||||
setToolUuids,
|
||||
documentUuids,
|
||||
setDocumentUuids,
|
||||
tools,
|
||||
documents,
|
||||
}: StartCallEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -414,9 +466,20 @@ const StartCallEditForm = ({
|
|||
<ToolSelector
|
||||
value={toolUuids}
|
||||
onChange={setToolUuids}
|
||||
tools={tools}
|
||||
description="Select tools that the agent can invoke during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="pt-4 border-t mt-4">
|
||||
<DocumentSelector
|
||||
value={documentUuids}
|
||||
onChange={setDocumentUuids}
|
||||
documents={documents}
|
||||
description="Select documents from the knowledge base that the agent can reference during this conversation step."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export type FlowNodeData = {
|
|||
};
|
||||
// Tools - array of tool UUIDs that can be invoked by this node
|
||||
tool_uuids?: string[];
|
||||
// Documents - array of knowledge base document UUIDs that can be referenced by this node
|
||||
document_uuids?: string[];
|
||||
}
|
||||
|
||||
export type FlowNode = {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
Database,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Home,
|
||||
|
|
@ -114,6 +115,11 @@ export function AppSidebar() {
|
|||
url: "/tools",
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
title: "Files",
|
||||
url: "/files",
|
||||
icon: Database,
|
||||
},
|
||||
// {
|
||||
// title: "Integrations",
|
||||
// url: "/integrations",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export type SaveUserConfigFunctionParams = {
|
|||
stt?: {
|
||||
[key: string]: string | number;
|
||||
} | null;
|
||||
embeddings?: {
|
||||
[key: string]: string | number;
|
||||
} | null;
|
||||
test_phone_number?: string | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue