mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-04 10:52:17 +02:00
feat: add hybrid text + recording functionality in agents (#191)
* feat: add recording feature in agents * chore: pin pipecat version * feat: show usage in UI * chore: update pipecat
This commit is contained in:
parent
f075bcb623
commit
494c60d774
43 changed files with 2865 additions and 397 deletions
24
ui/src/app/handler/[...stack]/BackButton.tsx
Normal file
24
ui/src/app/handler/[...stack]/BackButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function BackButton() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<header className="flex items-center border-b px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { StackHandler } from "@stackframe/stack";
|
|||
|
||||
import { getAuthProvider } from "@/lib/auth/config";
|
||||
|
||||
import { BackButton } from "./BackButton";
|
||||
|
||||
export default async function Handler(props: unknown) {
|
||||
const authProvider = await getAuthProvider();
|
||||
|
||||
|
|
@ -18,9 +20,16 @@ export default async function Handler(props: unknown) {
|
|||
const { getStackServerApp } = await import("@/lib/auth/server");
|
||||
const app = await getStackServerApp();
|
||||
|
||||
return <StackHandler
|
||||
fullPage
|
||||
app={app!}
|
||||
routeProps={props}
|
||||
/>;
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<BackButton />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<StackHandler
|
||||
fullPage
|
||||
app={app!}
|
||||
routeProps={props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { Calendar, ChevronLeft, ChevronRight, Globe } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Globe } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
|
||||
import { getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet,getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import type { CurrentUsageResponse, DailyUsageBreakdownResponse,UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
|
|
@ -37,9 +37,9 @@ export default function UsagePage() {
|
|||
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
|
||||
const auth = useAuth();
|
||||
|
||||
// Current usage state
|
||||
const [currentUsage, setCurrentUsage] = useState<CurrentUsageResponse | null>(null);
|
||||
const [isLoadingCurrent, setIsLoadingCurrent] = useState(true);
|
||||
// MPS credits state
|
||||
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
|
||||
|
||||
// Usage history state
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
|
||||
|
|
@ -68,19 +68,18 @@ export default function UsagePage() {
|
|||
const [savingTimezone, setSavingTimezone] = useState(false);
|
||||
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
|
||||
|
||||
// Fetch current usage
|
||||
const fetchCurrentUsage = useCallback(async () => {
|
||||
// Fetch MPS credits
|
||||
const fetchMpsCredits = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
try {
|
||||
const response = await getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet();
|
||||
|
||||
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
|
||||
if (response.data) {
|
||||
setCurrentUsage(response.data);
|
||||
setMpsCredits(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current usage:', error);
|
||||
console.error('Failed to fetch MPS credits:', error);
|
||||
} finally {
|
||||
setIsLoadingCurrent(false);
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
|
|
@ -195,10 +194,10 @@ export default function UsagePage() {
|
|||
// Initial load - fetch when auth becomes available
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchCurrentUsage();
|
||||
fetchMpsCredits();
|
||||
fetchUsageHistory(currentPage, activeFilters);
|
||||
}
|
||||
}, [auth.isAuthenticated, currentPage, activeFilters, fetchUsageHistory, fetchCurrentUsage]);
|
||||
}, [auth.isAuthenticated, currentPage, activeFilters, fetchUsageHistory, fetchMpsCredits]);
|
||||
|
||||
// Fetch daily usage when organizationPricing becomes available
|
||||
useEffect(() => {
|
||||
|
|
@ -259,20 +258,6 @@ export default function UsagePage() {
|
|||
router.push(`/workflow/${run.workflow_id}/run/${run.id}`);
|
||||
};
|
||||
|
||||
// Format date for display with timezone support
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const tzValue = typeof selectedTimezone === 'string' ? selectedTimezone : selectedTimezone.value;
|
||||
// Use local timezone if none selected (during loading)
|
||||
const effectiveTz = tzValue || localTimezone;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
timeZone: effectiveTz,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Format datetime for display with timezone support
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
|
|
@ -383,68 +368,42 @@ export default function UsagePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Period Card */}
|
||||
{/* MPS Credits Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Current Billing Period</CardTitle>
|
||||
<CardTitle>Dograh Model Credits</CardTitle>
|
||||
<CardDescription>
|
||||
{currentUsage && `${formatDate(currentUsage.period_start)} - ${formatDate(currentUsage.period_end)}`}
|
||||
These track usage of Dograh models using Dograh Service Keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCurrent ? (
|
||||
{isLoadingCredits ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : currentUsage ? (
|
||||
) : mpsCredits ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
{organizationPricing?.price_per_second_usd ? (
|
||||
<>
|
||||
<p className="text-2xl font-bold">
|
||||
${(currentUsage.used_amount_usd || 0).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Total Cost (USD)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Rate: ${(organizationPricing.price_per_second_usd * 60).toFixed(4)}/minute
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold">
|
||||
{currentUsage.used_dograh_tokens.toLocaleString()} / {currentUsage.quota_dograh_tokens.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Dograh Tokens</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-2xl font-bold">
|
||||
{mpsCredits.total_credits_used.toFixed(2)} <span className="text-lg font-normal text-muted-foreground">/ {mpsCredits.total_quota.toFixed(2)}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Credits Used</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
|
||||
<p className="text-sm text-muted-foreground">Remaining</p>
|
||||
</div>
|
||||
{!organizationPricing?.price_per_second_usd && (
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{currentUsage.percentage_used}%</p>
|
||||
<p className="text-sm text-muted-foreground">Used</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!organizationPricing?.price_per_second_usd && (
|
||||
<Progress value={currentUsage.percentage_used} className="h-3" />
|
||||
{mpsCredits.total_quota > 0 && (
|
||||
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Next refresh: {formatDate(currentUsage.next_refresh_date)}
|
||||
</div>
|
||||
<div>
|
||||
Total Duration: <span className="font-medium text-foreground">{formatDuration(currentUsage.total_duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Unable to load usage data</p>
|
||||
<p className="text-muted-foreground">No Dograh service keys configured. Set up a service key in your model configuration to see usage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import {
|
|||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BookA, BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import { BookA, BrushCleaning, Maximize2, Mic, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import type { DocumentResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import type { DocumentResponseSchema, RecordingResponseSchema, 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';
|
||||
|
|
@ -23,6 +23,7 @@ import { ConfigurationsDialog } from './components/ConfigurationsDialog';
|
|||
import { DictionaryDialog } from './components/DictionaryDialog';
|
||||
import { EmbedDialog } from './components/EmbedDialog';
|
||||
import { PhoneCallDialog } from './components/PhoneCallDialog';
|
||||
import { RecordingsDialog } from './components/RecordingsDialog';
|
||||
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
|
||||
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
|
|
@ -67,8 +68,10 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
|
||||
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
|
||||
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
|
||||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
|
|
@ -102,7 +105,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
user,
|
||||
});
|
||||
|
||||
// Fetch documents and tools once for the entire workflow
|
||||
// Fetch documents, tools, and recordings once for the entire workflow
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
|
|
@ -119,13 +122,25 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
if (toolsResponse.data) {
|
||||
setTools(toolsResponse.data);
|
||||
}
|
||||
|
||||
// Fetch recordings for this workflow
|
||||
try {
|
||||
const recordingsResponse = await listRecordingsApiV1WorkflowRecordingsGet({
|
||||
query: { workflow_id: workflowId },
|
||||
});
|
||||
if (recordingsResponse.data) {
|
||||
setRecordings(recordingsResponse.data.recordings);
|
||||
}
|
||||
} catch {
|
||||
// Recordings API may not be available yet; silently ignore
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch documents and tools:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [workflowId]);
|
||||
|
||||
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
|
|
@ -137,8 +152,9 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
const workflowContextValue = useMemo(() => ({
|
||||
saveWorkflow,
|
||||
documents,
|
||||
tools
|
||||
}), [saveWorkflow, documents, tools]);
|
||||
tools,
|
||||
recordings,
|
||||
}), [saveWorkflow, documents, tools, recordings]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
|
|
@ -251,6 +267,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsRecordingsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Recordings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -389,6 +421,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
workflowId={workflowId}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
<RecordingsDialog
|
||||
open={isRecordingsDialogOpen}
|
||||
onOpenChange={setIsRecordingsDialogOpen}
|
||||
workflowId={workflowId}
|
||||
onRecordingsChange={setRecordings}
|
||||
/>
|
||||
</div>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
|
|
|
|||
314
ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx
Normal file
314
ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { Loader2, Trash2Icon, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
createRecordingApiV1WorkflowRecordingsPost,
|
||||
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
|
||||
getUploadUrlApiV1WorkflowRecordingsUploadUrlPost,
|
||||
listRecordingsApiV1WorkflowRecordingsGet,
|
||||
} from "@/client";
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
interface RecordingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowId: number;
|
||||
onRecordingsChange?: (recordings: RecordingResponseSchema[]) => void;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
export const RecordingsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
onRecordingsChange,
|
||||
}: RecordingsDialogProps) => {
|
||||
const { userConfig } = useUserConfig();
|
||||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const ttsProvider = (userConfig?.tts?.provider as string) ?? "";
|
||||
const ttsModel = (userConfig?.tts?.model as string) ?? "";
|
||||
const ttsVoiceId = (userConfig?.tts?.voice as string) ?? "";
|
||||
|
||||
const fetchRecordings = useCallback(async () => {
|
||||
if (!workflowId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listRecordingsApiV1WorkflowRecordingsGet({
|
||||
query: {
|
||||
workflow_id: workflowId,
|
||||
tts_provider: ttsProvider || undefined,
|
||||
tts_model: ttsModel || undefined,
|
||||
tts_voice_id: ttsVoiceId || undefined,
|
||||
},
|
||||
});
|
||||
const recs = result.data?.recordings ?? [];
|
||||
setRecordings(recs);
|
||||
onRecordingsChange?.(recs);
|
||||
} catch {
|
||||
setError("Failed to load recordings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, ttsProvider, ttsModel, ttsVoiceId, onRecordingsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRecordings();
|
||||
setError(null);
|
||||
setTranscript("");
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}, [open, fetchRecordings]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !transcript.trim()) return;
|
||||
if (!ttsProvider || !ttsModel || !ttsVoiceId) {
|
||||
setError(
|
||||
"TTS configuration (provider, model, voice) must be set in your user configuration before uploading."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Get presigned URL
|
||||
const uploadUrlResponse =
|
||||
await getUploadUrlApiV1WorkflowRecordingsUploadUrlPost({
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
filename: selectedFile.name,
|
||||
mime_type: selectedFile.type || "audio/wav",
|
||||
file_size: selectedFile.size,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadUrlResponse.data) {
|
||||
throw new Error("Failed to get upload URL");
|
||||
}
|
||||
|
||||
const { upload_url, recording_id, storage_key } =
|
||||
uploadUrlResponse.data;
|
||||
|
||||
// Step 2: Upload file directly to storage
|
||||
const uploadResponse = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: selectedFile,
|
||||
headers: {
|
||||
"Content-Type": selectedFile.type || "audio/wav",
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("File upload failed");
|
||||
}
|
||||
|
||||
// Step 3: Create recording record
|
||||
await createRecordingApiV1WorkflowRecordingsPost({
|
||||
body: {
|
||||
recording_id,
|
||||
workflow_id: workflowId,
|
||||
tts_provider: ttsProvider,
|
||||
tts_model: ttsModel,
|
||||
tts_voice_id: ttsVoiceId,
|
||||
transcript: transcript.trim(),
|
||||
storage_key,
|
||||
metadata: {
|
||||
original_filename: selectedFile.name,
|
||||
file_size_bytes: selectedFile.size,
|
||||
mime_type: selectedFile.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form and refresh list
|
||||
setTranscript("");
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
await fetchRecordings();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to upload recording"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (recordingId: string) => {
|
||||
try {
|
||||
await deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete({
|
||||
path: { recording_id: recordingId },
|
||||
});
|
||||
await fetchRecordings();
|
||||
} catch {
|
||||
setError("Failed to delete recording");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Workflow Recordings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload audio recordings for hybrid prompts. Recordings are
|
||||
scoped to your current TTS configuration. Use{" "}
|
||||
<code className="text-xs bg-muted px-1 rounded">@</code> in
|
||||
prompt fields to insert them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Current TTS Config */}
|
||||
<div className="rounded-md border p-3 bg-muted/30 text-sm space-y-1">
|
||||
<div className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Current TTS Configuration
|
||||
</div>
|
||||
{ttsProvider ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="bg-background px-2 py-0.5 rounded border">
|
||||
Provider: {ttsProvider}
|
||||
</span>
|
||||
<span className="bg-background px-2 py-0.5 rounded border">
|
||||
Model: {ttsModel}
|
||||
</span>
|
||||
<span className="bg-background px-2 py-0.5 rounded border truncate max-w-[200px]">
|
||||
VoiceID: {ttsVoiceId}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
No TTS configuration found. Set it in Model Configurations.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 rounded-md p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="space-y-3 border rounded-md p-3">
|
||||
<Label className="text-sm font-medium">Upload New Recording</Label>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Audio File
|
||||
</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
if (file && file.size > MAX_FILE_SIZE) {
|
||||
setError(
|
||||
`File size (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds the maximum allowed size of 5MB.`
|
||||
);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSelectedFile(file);
|
||||
}}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Max 5MB
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Transcript
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="What does this recording say?"
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !transcript.trim() || uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{uploading ? "Uploading..." : "Upload Recording"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recordings List */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Recordings{" "}
|
||||
{!loading && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({recordings.length})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : recordings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No recordings yet for this TTS configuration.
|
||||
</p>
|
||||
) : (
|
||||
recordings.map((rec) => (
|
||||
<div
|
||||
key={rec.recording_id}
|
||||
className="flex items-start gap-2 p-2 border rounded-md"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||
{rec.recording_id}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 break-all line-clamp-2">
|
||||
{rec.transcript}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(rec.recording_id)}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { DocumentResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
import type { RecordingResponseSchema } from '@/client/types.gen';
|
||||
|
||||
interface WorkflowContextType {
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
documents?: DocumentResponseSchema[];
|
||||
tools?: ToolResponse[];
|
||||
recordings?: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
|
||||
|
|
|
|||
|
|
@ -319,18 +319,10 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
setFeedbackMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.type === 'bot-text' && !last.final) {
|
||||
// Append to existing bot message with space if needed
|
||||
const existingText = last.text;
|
||||
const newText = message.payload.text;
|
||||
// Add space between chunks if previous doesn't end with space
|
||||
// and new doesn't start with space or punctuation
|
||||
const needsSpace = existingText.length > 0 &&
|
||||
!existingText.endsWith(' ') &&
|
||||
!newText.startsWith(' ') &&
|
||||
!/^[.,!?;:]/.test(newText);
|
||||
// Append to existing bot message
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, text: existingText + (needsSpace ? ' ' : '') + newText }
|
||||
{ ...last, text: last.text + message.payload.text }
|
||||
];
|
||||
}
|
||||
// Start new bot message
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function processTranscriptEvents(events: TranscriptEvent[]): ProcessedMes
|
|||
} else if (event.type === 'bot-text') {
|
||||
// Combine consecutive bot-text from the same turn
|
||||
if (currentBotText && currentBotText.event.turn === event.turn) {
|
||||
currentBotText.text = currentBotText.text + ' ' + event.text;
|
||||
currentBotText.text = currentBotText.text + event.text;
|
||||
} else {
|
||||
flushBotText();
|
||||
currentBotText = { event, text: event.text };
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ import type { ClientOptions } from './types.gen';
|
|||
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'http://127.0.0.1:8000'
|
||||
baseUrl: 'https://app.dograh.com'
|
||||
})));
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -755,6 +755,12 @@ export type LoginRequest = {
|
|||
password: string;
|
||||
};
|
||||
|
||||
export type MpsCreditsResponse = {
|
||||
total_credits_used: number;
|
||||
remaining_credits: number;
|
||||
total_quota: number;
|
||||
};
|
||||
|
||||
export type PresignedUploadUrlRequest = {
|
||||
/**
|
||||
* CSV filename
|
||||
|
|
@ -790,6 +796,116 @@ export type ProcessDocumentRequestSchema = {
|
|||
s3_key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for creating a recording record after upload.
|
||||
*/
|
||||
export type RecordingCreateRequestSchema = {
|
||||
/**
|
||||
* Short recording ID from upload step
|
||||
*/
|
||||
recording_id: string;
|
||||
/**
|
||||
* Workflow ID
|
||||
*/
|
||||
workflow_id: number;
|
||||
/**
|
||||
* TTS provider (e.g. elevenlabs)
|
||||
*/
|
||||
tts_provider: string;
|
||||
/**
|
||||
* TTS model name
|
||||
*/
|
||||
tts_model: string;
|
||||
/**
|
||||
* TTS voice identifier
|
||||
*/
|
||||
tts_voice_id: string;
|
||||
/**
|
||||
* User-provided transcript of the recording
|
||||
*/
|
||||
transcript: string;
|
||||
/**
|
||||
* Storage key from upload step
|
||||
*/
|
||||
storage_key: string;
|
||||
/**
|
||||
* Optional metadata (file_size, duration, etc.)
|
||||
*/
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for list of recordings.
|
||||
*/
|
||||
export type RecordingListResponseSchema = {
|
||||
recordings: Array<RecordingResponseSchema>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for a single recording.
|
||||
*/
|
||||
export type RecordingResponseSchema = {
|
||||
id: number;
|
||||
recording_id: string;
|
||||
workflow_id: number;
|
||||
organization_id: number;
|
||||
tts_provider: string;
|
||||
tts_model: string;
|
||||
tts_voice_id: string;
|
||||
transcript: string;
|
||||
storage_key: string;
|
||||
storage_backend: string;
|
||||
metadata: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for getting a presigned upload URL.
|
||||
*/
|
||||
export type RecordingUploadRequestSchema = {
|
||||
/**
|
||||
* Workflow ID this recording belongs to
|
||||
*/
|
||||
workflow_id: number;
|
||||
/**
|
||||
* Original filename of the audio file
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* MIME type of the audio file
|
||||
*/
|
||||
mime_type?: string;
|
||||
/**
|
||||
* File size in bytes (max 5MB)
|
||||
*/
|
||||
file_size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema with presigned upload URL.
|
||||
*/
|
||||
export type RecordingUploadResponseSchema = {
|
||||
/**
|
||||
* Presigned URL for uploading the audio
|
||||
*/
|
||||
upload_url: string;
|
||||
/**
|
||||
* Short unique recording ID
|
||||
*/
|
||||
recording_id: string;
|
||||
/**
|
||||
* Storage key where file will be uploaded
|
||||
*/
|
||||
storage_key: string;
|
||||
};
|
||||
|
||||
export type RetryConfigRequest = {
|
||||
enabled?: boolean;
|
||||
max_retries?: number;
|
||||
|
|
@ -4268,6 +4384,39 @@ export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse
|
|||
|
||||
export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse = GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses[keyof GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses];
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/usage/mps-credits';
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetError = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors];
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsCreditsResponse;
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses];
|
||||
|
||||
export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
@ -5065,6 +5214,155 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
|
|||
|
||||
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses];
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostData = {
|
||||
body: RecordingUploadRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/workflow-recordings/upload-url';
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostError = GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors[keyof GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostErrors];
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RecordingUploadResponseSchema;
|
||||
};
|
||||
|
||||
export type GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponse = GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses[keyof GetUploadUrlApiV1WorkflowRecordingsUploadUrlPostResponses];
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query: {
|
||||
/**
|
||||
* Workflow ID
|
||||
*/
|
||||
workflow_id: number;
|
||||
/**
|
||||
* Filter by TTS provider
|
||||
*/
|
||||
tts_provider?: string | null;
|
||||
/**
|
||||
* Filter by TTS model
|
||||
*/
|
||||
tts_model?: string | null;
|
||||
/**
|
||||
* Filter by TTS voice ID
|
||||
*/
|
||||
tts_voice_id?: string | null;
|
||||
};
|
||||
url: '/api/v1/workflow-recordings/';
|
||||
};
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetError = ListRecordingsApiV1WorkflowRecordingsGetErrors[keyof ListRecordingsApiV1WorkflowRecordingsGetErrors];
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RecordingListResponseSchema;
|
||||
};
|
||||
|
||||
export type ListRecordingsApiV1WorkflowRecordingsGetResponse = ListRecordingsApiV1WorkflowRecordingsGetResponses[keyof ListRecordingsApiV1WorkflowRecordingsGetResponses];
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostData = {
|
||||
body: RecordingCreateRequestSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/workflow-recordings/';
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostError = CreateRecordingApiV1WorkflowRecordingsPostErrors[keyof CreateRecordingApiV1WorkflowRecordingsPostErrors];
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RecordingResponseSchema;
|
||||
};
|
||||
|
||||
export type CreateRecordingApiV1WorkflowRecordingsPostResponse = CreateRecordingApiV1WorkflowRecordingsPostResponses[keyof CreateRecordingApiV1WorkflowRecordingsPostResponses];
|
||||
|
||||
export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
recording_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/workflow-recordings/{recording_id}';
|
||||
};
|
||||
|
||||
export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteError = DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors[keyof DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors];
|
||||
|
||||
export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type SignupApiV1AuthSignupPostData = {
|
||||
body: SignupRequest;
|
||||
path?: never;
|
||||
|
|
@ -5180,5 +5478,5 @@ export type HealthApiV1HealthGetResponses = {
|
|||
export type HealthApiV1HealthGetResponse = HealthApiV1HealthGetResponses[keyof HealthApiV1HealthGetResponses];
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: 'http://127.0.0.1:8000' | (string & {});
|
||||
baseUrl: 'https://app.dograh.com' | 'http://localhost:8000' | (string & {});
|
||||
};
|
||||
|
|
|
|||
213
ui/src/components/flow/MentionTextarea.tsx
Normal file
213
ui/src/components/flow/MentionTextarea.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
recordings?: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
export function MentionTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
recordings = [],
|
||||
}: MentionTextareaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState<number | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Convert recordings to mention items
|
||||
const items: MentionItem[] = useMemo(
|
||||
() =>
|
||||
recordings.map((r) => ({
|
||||
id: r.recording_id,
|
||||
name: r.transcript,
|
||||
description: r.transcript,
|
||||
})),
|
||||
[recordings]
|
||||
);
|
||||
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.id.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const insertMention = useCallback(
|
||||
(item: MentionItem) => {
|
||||
if (mentionStartIndex === null) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const before = value.slice(0, mentionStartIndex);
|
||||
const after = value.slice(textarea.selectionStart);
|
||||
const mentionText = `RECORDING_ID: ${item.id} [ ${item.description} ]`;
|
||||
const newValue = before + mentionText + after;
|
||||
|
||||
onChange(newValue);
|
||||
setShowDropdown(false);
|
||||
setQuery("");
|
||||
setMentionStartIndex(null);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Restore cursor position after the inserted mention
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = before.length + mentionText.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(cursorPos, cursorPos);
|
||||
});
|
||||
},
|
||||
[mentionStartIndex, value, onChange]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
onChange(newValue);
|
||||
|
||||
// Look backwards from cursor to find an unmatched "@"
|
||||
const textBeforeCursor = newValue.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textBetween = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
// Only trigger if there's no space before the query or it's at the start
|
||||
const charBeforeAt = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : " ";
|
||||
if (
|
||||
(charBeforeAt === " " || charBeforeAt === "\n" || lastAtIndex === 0) &&
|
||||
!textBetween.includes(" ")
|
||||
) {
|
||||
setShowDropdown(true);
|
||||
setQuery(textBetween);
|
||||
setMentionStartIndex(lastAtIndex);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowDropdown(false);
|
||||
setQuery("");
|
||||
setMentionStartIndex(null);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showDropdown || filtered.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filtered.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertMention(filtered[selectedIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
},
|
||||
[showDropdown, filtered, selectedIndex, insertMention]
|
||||
);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
textareaRef.current &&
|
||||
!textareaRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (!showDropdown || !dropdownRef.current) return;
|
||||
const selected = dropdownRef.current.querySelector("[data-selected='true']");
|
||||
selected?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex, showDropdown]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
{showDropdown && filtered.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
{filtered.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-selected={index === selectedIndex}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 px-3 py-2 text-left text-sm cursor-pointer hover:bg-accent",
|
||||
index === selectedIndex && "bg-accent"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent textarea blur
|
||||
insertMention(item);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">
|
||||
{item.id}
|
||||
</code>
|
||||
<span className="font-medium truncate">{item.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showDropdown && filtered.length === 0 && items.length === 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md p-3 text-sm text-muted-foreground">
|
||||
No recordings found. Upload recordings via the Recordings panel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,9 +3,10 @@ import { Edit, FileText, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-re
|
|||
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 type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from "@/client/types.gen";
|
||||
import { DocumentBadges } from "@/components/flow/DocumentBadges";
|
||||
import { DocumentSelector } from "@/components/flow/DocumentSelector";
|
||||
import { MentionTextarea } from "@/components/flow/MentionTextarea";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -42,6 +43,7 @@ interface AgentNodeEditFormProps {
|
|||
setDocumentUuids: (value: string[]) => void;
|
||||
tools: ToolResponse[];
|
||||
documents: DocumentResponseSchema[];
|
||||
recordings: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
interface AgentNodeProps extends NodeProps {
|
||||
|
|
@ -50,7 +52,7 @@ interface AgentNodeProps extends NodeProps {
|
|||
|
||||
export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
||||
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
|
||||
const { saveWorkflow, tools, documents } = useWorkflow();
|
||||
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt);
|
||||
|
|
@ -229,6 +231,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setDocumentUuids={setDocumentUuids}
|
||||
tools={tools ?? []}
|
||||
documents={documents ?? []}
|
||||
recordings={recordings ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -257,6 +260,7 @@ const AgentNodeEditForm = ({
|
|||
setDocumentUuids,
|
||||
tools,
|
||||
documents,
|
||||
recordings,
|
||||
}: AgentNodeEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -318,13 +322,12 @@ const AgentNodeEditForm = ({
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] max-h-[300px] resize-none"
|
||||
style={{
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
onChange={setPrompt}
|
||||
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
|
||||
placeholder="Enter a prompt"
|
||||
recordings={recordings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
|
|||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { MentionTextarea } from "@/components/flow/MentionTextarea";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -29,6 +31,7 @@ interface EndCallEditFormProps {
|
|||
setVariables: (vars: ExtractionVariable[]) => void;
|
||||
addGlobalPrompt: boolean;
|
||||
setAddGlobalPrompt: (value: boolean) => void;
|
||||
recordings: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
interface EndCallNodeProps extends NodeProps {
|
||||
|
|
@ -40,7 +43,7 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
|
|||
id,
|
||||
additionalData: { is_end: true }
|
||||
});
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { saveWorkflow, recordings } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt);
|
||||
|
|
@ -157,6 +160,7 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
|
|||
setVariables={setVariables}
|
||||
addGlobalPrompt={addGlobalPrompt}
|
||||
setAddGlobalPrompt={setAddGlobalPrompt}
|
||||
recordings={recordings ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -177,6 +181,7 @@ const EndCallEditForm = ({
|
|||
setVariables,
|
||||
addGlobalPrompt,
|
||||
setAddGlobalPrompt,
|
||||
recordings,
|
||||
}: EndCallEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -216,14 +221,12 @@ const EndCallEditForm = ({
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] max-h-[300px] resize-none"
|
||||
style={{
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
placeholder="Enter a dynamic prompt"
|
||||
onChange={setPrompt}
|
||||
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
|
||||
placeholder="Enter a prompt"
|
||||
recordings={recordings}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { Edit, Headset, Trash2Icon } from "lucide-react";
|
|||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { MentionTextarea } from "@/components/flow/MentionTextarea";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
|
||||
import { NodeContent } from "./common/NodeContent";
|
||||
|
|
@ -20,6 +21,7 @@ interface GlobalNodeEditFormProps {
|
|||
setPrompt: (value: string) => void;
|
||||
name: string;
|
||||
setName: (value: string) => void;
|
||||
recordings: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
interface GlobalNodeProps extends NodeProps {
|
||||
|
|
@ -28,7 +30,7 @@ interface GlobalNodeProps extends NodeProps {
|
|||
|
||||
export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
|
||||
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const { saveWorkflow, recordings } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt);
|
||||
|
|
@ -118,6 +120,7 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
|
|||
setPrompt={setPrompt}
|
||||
name={name}
|
||||
setName={setName}
|
||||
recordings={recordings ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -129,7 +132,8 @@ const GlobalNodeEditForm = ({
|
|||
prompt,
|
||||
setPrompt,
|
||||
name,
|
||||
setName
|
||||
setName,
|
||||
recordings,
|
||||
}: GlobalNodeEditFormProps) => {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -146,13 +150,12 @@ const GlobalNodeEditForm = ({
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
This is the global prompt. This will be added to the system prompt of all the agents.
|
||||
</Label>
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] max-h-[300px] resize-none"
|
||||
style={{
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
onChange={setPrompt}
|
||||
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
|
||||
placeholder="Enter a prompt"
|
||||
recordings={recordings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ 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 type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { DocumentBadges } from "@/components/flow/DocumentBadges";
|
||||
import { DocumentSelector } from "@/components/flow/DocumentSelector";
|
||||
import { MentionTextarea } from "@/components/flow/MentionTextarea";
|
||||
import { ToolBadges } from "@/components/flow/ToolBadges";
|
||||
import { ToolSelector } from "@/components/flow/ToolSelector";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -48,6 +50,7 @@ interface StartCallEditFormProps {
|
|||
setDocumentUuids: (value: string[]) => void;
|
||||
tools: ToolResponse[];
|
||||
documents: DocumentResponseSchema[];
|
||||
recordings: RecordingResponseSchema[];
|
||||
}
|
||||
|
||||
interface StartCallNodeProps extends NodeProps {
|
||||
|
|
@ -59,7 +62,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
id,
|
||||
additionalData: { is_start: true }
|
||||
});
|
||||
const { saveWorkflow, tools, documents } = useWorkflow();
|
||||
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
|
||||
|
||||
// Form state
|
||||
const [prompt, setPrompt] = useState(data.prompt ?? "");
|
||||
|
|
@ -248,6 +251,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setDocumentUuids={setDocumentUuids}
|
||||
tools={tools ?? []}
|
||||
documents={documents ?? []}
|
||||
recordings={recordings ?? []}
|
||||
/>
|
||||
)}
|
||||
</NodeEditDialog>
|
||||
|
|
@ -282,6 +286,7 @@ const StartCallEditForm = ({
|
|||
setDocumentUuids,
|
||||
tools,
|
||||
documents,
|
||||
recordings,
|
||||
}: StartCallEditFormProps) => {
|
||||
const handleVariableNameChange = (idx: number, value: string) => {
|
||||
const newVars = [...variables];
|
||||
|
|
@ -325,14 +330,12 @@ const StartCallEditForm = ({
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] max-h-[300px] resize-none"
|
||||
style={{
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
onChange={setPrompt}
|
||||
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
|
||||
placeholder="Enter a prompt"
|
||||
recordings={recordings}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue