feat: agent versioning and model configurations override (#227)

* feat: add tests and migrations

* feat: workflow versioning among published and draft

* feat: add a new settings page to simplify workflow detail page

* fix: fix tsclient generation
This commit is contained in:
Abhishek 2026-04-08 19:20:31 +05:30 committed by GitHub
parent f5fa9ce717
commit 38d1d928b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 10158 additions and 3131 deletions

View file

@ -88,7 +88,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
if (response.data) {
const integrations = Array.isArray(response.data) ? response.data : [response.data];
const googleSheet = integrations.find(i => i.provider === 'google-sheet');
const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet');
setGoogleIntegration(googleSheet || null);
}
} catch (error) {

View file

@ -106,7 +106,7 @@ export default function EditCampaignPage() {
setScheduleEnabled(c.schedule_config.enabled);
setScheduleTimezone(c.schedule_config.timezone);
if (c.schedule_config.slots.length > 0) {
setTimeSlots(c.schedule_config.slots.map(s => ({ ...s })));
setTimeSlots(c.schedule_config.slots.map((s: TimeSlot) => ({ ...s })));
}
}

View file

@ -9,6 +9,7 @@ import {
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
} from '@/client/sdk.gen';
import type { WorkflowRunDetail } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { Card } from '@/components/ui/card';
@ -140,7 +141,7 @@ export default function ReportsPage() {
if (response.data && response.data.length > 0) {
// Prepare CSV content
const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL'];
const rows = response.data.map(run => {
const rows = response.data.map((run: WorkflowRunDetail) => {
const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`;
return [
run.phone_number || '',
@ -153,7 +154,7 @@ export default function ReportsPage() {
// Create CSV content
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
...rows.map((row: string[]) => row.map((cell: string) => `"${cell}"`).join(','))
].join('\n');
// Create blob and download

View file

@ -6,10 +6,11 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { BookA, BrushCleaning, Maximize2, Mic, Minus, PhoneOff, Plus, Rocket, Settings, Variable } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { BrushCleaning, Maximize2, Minus, Plus, Settings } from 'lucide-react';
import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, 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';
@ -20,13 +21,8 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, QANode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
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 { VoicemailDetectionDialog } from './components/VoicemailDetectionDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
@ -61,22 +57,27 @@ interface RenderWorkflowProps {
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
initialVersionNumber?: number | null;
initialVersionStatus?: string | null;
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user }: RenderWorkflowProps) {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
const router = useRouter();
const { userConfig } = useUserConfig();
const ttsProvider = (userConfig?.tts?.provider as string) ?? "";
const ttsModel = (userConfig?.tts?.model as string) ?? "";
const ttsVoiceId = (userConfig?.tts?.voice as string) ?? "";
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
const [isVoicemailDialogOpen, setIsVoicemailDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
// Version info that updates immediately from the GET/save/publish responses.
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
const versionsFetched = useRef(false);
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
@ -89,9 +90,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setEdges,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,
@ -100,10 +100,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations,
dictionary,
saveDictionary
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -113,6 +109,123 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
user,
});
// Derive hasDraft from the current version status
const hasDraft = currentVersionStatus === "draft";
// Fetch workflow versions, optionally forcing a refresh
const fetchVersions = useCallback(async (force = false) => {
if (versionsFetched.current && !force) return;
setVersionsLoading(true);
try {
const response = await getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet({
path: { workflow_id: workflowId },
});
const data = response.data as WorkflowVersion[] | undefined;
if (data) {
setVersions(data);
// Set active version to draft if exists, else published
const current = data.find((v) => v.status === "draft") ?? data.find((v) => v.status === "published");
if (current) {
setActiveVersionId(current.id);
setCurrentVersionNumber(current.version_number);
setCurrentVersionStatus(current.status);
}
}
versionsFetched.current = true;
} finally {
setVersionsLoading(false);
}
}, [workflowId]);
const handleOpenVersionPanel = useCallback(() => {
setIsVersionPanelOpen(true);
fetchVersions();
}, [fetchVersions]);
const handleSelectVersion = useCallback((version: WorkflowVersion) => {
setActiveVersionId(version.id);
const wfJson = version.workflow_json;
const flowNodes = (wfJson.nodes ?? []) as FlowNode[];
const flowEdges = (wfJson.edges ?? []) as FlowEdge[];
// Update the Zustand store directly instead of rfInstance.current.setNodes().
// This keeps data flow unidirectional (store → props → ReactFlow) and avoids
// xyflow's d3 event handlers interfering with React's event delegation.
// The key={activeVersionId} on <ReactFlow> forces a clean remount.
setNodes(flowNodes);
setEdges(flowEdges);
// Never mark dirty when switching versions — historical versions are
// read-only, and loading the draft is restoring the saved state.
setIsDirty(false);
setIsVersionPanelOpen(false);
}, [setNodes, setEdges, setIsDirty]);
// Determine if we are viewing a historical (non-current) version.
// The "current" version is the draft if one exists, otherwise the published version.
// Anything else (archived, or published while a draft exists) is historical.
const isViewingHistoricalVersion = useMemo(() => {
if (!activeVersionId || versions.length === 0) return false;
const activeVersion = versions.find((v) => v.id === activeVersionId);
if (!activeVersion) return false;
if (activeVersion.status === "draft") return false;
if (activeVersion.status === "published" && !hasDraft) return false;
return true;
}, [activeVersionId, versions, hasDraft]);
// Return to the draft version, creating one from published if needed
const handleBackToDraft = useCallback(async () => {
const existingDraft = versions.find((v) => v.status === "draft");
if (existingDraft) {
handleSelectVersion(existingDraft);
return;
}
// No draft exists — ask the backend to create one from published
const response = await createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost({
path: { workflow_id: workflowId },
});
const draft = response.data;
if (draft) {
setCurrentVersionNumber(draft.version_number);
setCurrentVersionStatus(draft.status);
// Load draft nodes/edges via the Zustand store (same approach as handleSelectVersion)
const flowNodes = (draft.workflow_json?.nodes ?? []) as FlowNode[];
const flowEdges = (draft.workflow_json?.edges ?? []) as FlowEdge[];
setNodes(flowNodes);
setEdges(flowEdges);
setActiveVersionId(draft.id);
setIsDirty(false);
// Refresh the version list so the new draft appears
fetchVersions(true);
}
}, [versions, handleSelectVersion, workflowId, setNodes, setEdges, setIsDirty, fetchVersions]);
// After a successful publish, refresh the version list and update status
const handlePublished = useCallback(() => {
setCurrentVersionStatus("published");
fetchVersions(true);
}, [fetchVersions]);
// Compute version label for the header.
// Uses currentVersionNumber/Status which update immediately from save responses,
// falling back to the versions list for history navigation.
const activeVersionLabel = useMemo(() => {
// When viewing a version from the history panel, use the versions list
if (activeVersionId && versions.length > 0) {
const v = versions.find((ver) => ver.id === activeVersionId);
if (v) {
const statusSuffix = v.status === "draft" ? " (Draft)" : v.status === "published" ? " (Published)" : "";
return `v${v.version_number}${statusSuffix}`;
}
}
// Otherwise use the immediately-available version info from save responses
if (currentVersionNumber != null) {
const statusSuffix = currentVersionStatus === "draft" ? " (Draft)" : currentVersionStatus === "published" ? " (Published)" : "";
return `v${currentVersionNumber}${statusSuffix}`;
}
return undefined;
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -161,13 +274,37 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
type: "custom"
}), []);
// Guard saveWorkflow so it's a no-op when viewing a historical version.
// This is the single safety net that covers every save path: header button,
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
// Uses the save response to immediately update version label and hasDraft.
const guardedSaveWorkflow = useCallback(async (updateWorkflowDefinition?: boolean) => {
if (isViewingHistoricalVersion) return;
const result = await saveWorkflow(updateWorkflowDefinition);
if (result) {
// If the versions list has been fetched (user interacted with versioning
// or published), refresh it so that activeVersionId points to the correct
// version. This is critical when a save creates a new draft from a
// published version: without refreshing, activeVersionId would still
// point to the old published version, causing isViewingHistoricalVersion
// to incorrectly return true and lock the editor into read-only mode.
if (versionsFetched.current) {
await fetchVersions(true);
} else {
if (result.versionNumber != null) setCurrentVersionNumber(result.versionNumber);
if (result.versionStatus) setCurrentVersionStatus(result.versionStatus);
}
}
}, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]);
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({
saveWorkflow,
saveWorkflow: guardedSaveWorkflow,
documents,
tools,
recordings,
}), [saveWorkflow, documents, tools, recordings]);
readOnly: isViewingHistoricalVersion,
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
return (
<WorkflowProvider value={workflowContextValue}>
@ -180,21 +317,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
onHistoryClick={handleOpenVersionPanel}
activeVersionLabel={activeVersionLabel}
isViewingHistoricalVersion={isViewingHistoricalVersion}
onBackToDraft={handleBackToDraft}
hasDraft={hasDraft}
onPublished={handlePublished}
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={onConnect}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
@ -204,6 +349,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
@ -212,124 +362,46 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
color="#94a3b8"
/>
{/* Top-right controls - vertical layout */}
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsConfigurationsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsContextVarsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Variable className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsDictionaryDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<BookA className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Dictionary</p>
</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
variant="outline"
size="icon"
onClick={() => setIsVoicemailDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<PhoneOff className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Voicemail Detection</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsEmbedDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Rocket className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Deploy Workflow</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
@ -386,25 +458,27 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
</div>
@ -415,33 +489,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onClose={() => setIsAddNodePanelOpen(false)}
/>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
<DictionaryDialog
open={isDictionaryDialogOpen}
onOpenChange={setIsDictionaryDialogOpen}
dictionary={dictionary}
onSave={saveDictionary}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName}
<VersionHistoryPanel
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
versions={versions}
loading={versionsLoading}
activeVersionId={activeVersionId}
onSelectVersion={handleSelectVersion}
/>
<PhoneCallDialog
@ -450,22 +504,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowId={workflowId}
user={user}
/>
<RecordingsDialog
open={isRecordingsDialogOpen}
onOpenChange={setIsRecordingsDialogOpen}
workflowId={workflowId}
onRecordingsChange={setRecordings}
/>
{workflowConfigurations && (
<VoicemailDetectionDialog
open={isVoicemailDialogOpen}
onOpenChange={setIsVoicemailDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={(configurations) => saveWorkflowConfigurations(configurations, workflowName)}
/>
)}
</div>
</WorkflowProvider>
);

View file

@ -0,0 +1,54 @@
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { WorkflowConfigurations } from "@/types/workflow-configurations";
interface ModelConfigurationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowConfigurations: WorkflowConfigurations | null;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}
export const ModelConfigurationDialog = ({
open,
onOpenChange,
workflowConfigurations,
workflowName,
onSave,
}: ModelConfigurationDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Model Configuration</DialogTitle>
<DialogDescription>
Override global model settings for this workflow. Toggle individual services to customize.
</DialogDescription>
</DialogHeader>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations?.model_overrides}
submitLabel="Save"
onSave={async (config) => {
await onSave(
{
...workflowConfigurations,
model_overrides: config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
onOpenChange(false);
}}
/>
</DialogContent>
</Dialog>
);
};

View file

@ -9,7 +9,7 @@ import {
listRecordingsApiV1WorkflowRecordingsGet,
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
} from "@/client";
import type { RecordingResponseSchema } from "@/client/types.gen";
import type { RecordingResponseSchema, RecordingUploadResponseSchema } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -294,7 +294,7 @@ export const RecordingsDialog = ({
// Step 2: Upload all files to storage in parallel
await Promise.all(
items.map(async (item, idx) => {
items.map(async (item: RecordingUploadResponseSchema, idx: number) => {
const file = ready[idx].file;
const uploadResponse = await fetch(item.upload_url, {
method: "PUT",
@ -312,7 +312,7 @@ export const RecordingsDialog = ({
// Step 3: Create all recording records
await createRecordingsApiV1WorkflowRecordingsPost({
body: {
recordings: items.map((item, idx) => ({
recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({
recording_id: item.recording_id,
workflow_id: workflowId,
tts_provider: ttsProvider,

View file

@ -0,0 +1,133 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { FileText, LoaderCircle, X } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export interface WorkflowVersion {
id: number;
version_number: number;
status: string;
created_at: string;
published_at: string | null;
workflow_json: { nodes?: unknown[]; edges?: unknown[]; viewport?: unknown };
workflow_configurations: Record<string, unknown> | null;
template_context_variables: Record<string, string> | null;
}
interface VersionHistoryPanelProps {
isOpen: boolean;
onClose: () => void;
versions: WorkflowVersion[];
loading: boolean;
activeVersionId: number | null;
onSelectVersion: (version: WorkflowVersion) => void;
}
const statusLabel: Record<string, string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
};
const statusColor: Record<string, string> = {
draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
published: "bg-green-500/20 text-green-400 border-green-500/30",
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
export const VersionHistoryPanel = ({
isOpen,
onClose,
versions,
loading,
activeVersionId,
onSelectVersion,
}: VersionHistoryPanelProps) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
return (
<div
className={`fixed z-51 right-0 top-0 h-full w-80 bg-[#1a1a1a] border-l border-[#2a2a2a] shadow-lg transform transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="p-4 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-white">
Version History
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-gray-400 hover:text-white hover:bg-[#2a2a2a]"
>
<X className="w-5 h-5" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<LoaderCircle className="w-6 h-6 text-gray-400 animate-spin" />
</div>
) : versions.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No versions found.
</p>
) : (
<div className="space-y-2">
{versions.map((version) => {
const isActive = version.id === activeVersionId;
const date = version.published_at || version.created_at;
return (
<button
key={version.id}
onClick={() => onSelectVersion(version)}
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
isActive
? "border-teal-500/50 bg-teal-500/10"
: "border-[#2a2a2a] bg-[#222] hover:bg-[#2a2a2a]"
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-white">
v{version.version_number}
</span>
</div>
{version.status !== "archived" && (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${
statusColor[version.status] ?? ""
}`}
>
{statusLabel[version.status] ?? version.status}
</span>
)}
</div>
<p className="text-xs text-gray-500">
{formatDistanceToNow(new Date(date), {
addSuffix: true,
})}
</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
};

View file

@ -1,12 +1,15 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, MoreVertical, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost } from "@/client/sdk.gen";
import {
duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost,
publishWorkflowApiV1WorkflowWorkflowIdPublishPost,
} from "@/client/sdk.gen";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
@ -33,6 +36,12 @@ interface WorkflowEditorHeaderProps {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
onHistoryClick: () => void;
activeVersionLabel?: string;
isViewingHistoricalVersion: boolean;
onBackToDraft: () => void;
hasDraft: boolean;
onPublished: () => void;
}
export const WorkflowEditorHeader = ({
@ -43,11 +52,18 @@ export const WorkflowEditorHeader = ({
saveWorkflow,
onRun,
onPhoneCallClick,
onHistoryClick,
activeVersionLabel,
isViewingHistoricalVersion,
onBackToDraft,
hasDraft,
onPublished,
workflowId,
}: WorkflowEditorHeaderProps) => {
const router = useRouter();
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [duplicating, setDuplicating] = useState(false);
const [publishing, setPublishing] = useState(false);
const hasValidationErrors = workflowValidationErrors.length > 0;
const isCallDisabled = isDirty || hasValidationErrors;
@ -58,6 +74,25 @@ export const WorkflowEditorHeader = ({
setSavingWorkflow(false);
};
const handlePublish = async () => {
if (publishing) return;
setPublishing(true);
const promise = publishWorkflowApiV1WorkflowWorkflowIdPublishPost({
path: { workflow_id: workflowId },
});
toast.promise(promise, {
loading: "Publishing...",
success: "Workflow published successfully",
error: "Failed to publish workflow",
});
try {
await promise;
onPublished();
} finally {
setPublishing(false);
}
};
const handleBack = () => {
router.push("/workflow");
};
@ -121,10 +156,41 @@ export const WorkflowEditorHeader = ({
</div>
</div>
{/* Right section: Unsaved indicator + Call button + Save button */}
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
<div className="flex items-center gap-3">
{/* Unsaved changes indicator */}
{isDirty && (
{/* Read-only banner when viewing a historical version */}
{isViewingHistoricalVersion && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-blue-500/30 bg-blue-500/10">
<Eye className="w-4 h-4 text-blue-400" />
<span className="text-sm text-blue-400">
Viewing {activeVersionLabel} Read only
</span>
</div>
)}
{/* Back to Draft button when viewing history */}
{isViewingHistoricalVersion && (
<Button
onClick={onBackToDraft}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
Back to Draft
</Button>
)}
{/* Version history button */}
<button
onClick={onHistoryClick}
className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-[#3a3a3a] hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
<History className="w-4 h-4 text-gray-400" />
{activeVersionLabel && !isViewingHistoricalVersion && (
<span className="text-sm text-gray-300">{activeVersionLabel}</span>
)}
</button>
{/* Unsaved changes indicator (hidden when viewing history) */}
{isDirty && !isViewingHistoricalVersion && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/10">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-sm text-yellow-500">Unsaved changes</span>
@ -177,57 +243,83 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* Call button with dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
>
<Phone className="w-4 h-4" />
Call
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Web Call
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Delay opening dialog to next event cycle to allow DropdownMenu
// to clean up first, preventing pointer-events: none stuck on body
// See: https://github.com/radix-ui/primitives/issues/1241
setTimeout(onPhoneCallClick, 0);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Phone Call
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Call button with dropdown (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
>
<Phone className="w-4 h-4" />
Call
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Web Call
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Delay opening dialog to next event cycle to allow DropdownMenu
// to clean up first, preventing pointer-events: none stuck on body
// See: https://github.com/radix-ui/primitives/issues/1241
setTimeout(onPhoneCallClick, 0);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Phone Call
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Save button */}
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* Publish button (only when on draft with no unsaved changes) */}
{!isViewingHistoricalVersion && hasDraft && (
<Button
onClick={handlePublish}
disabled={isDirty || publishing || hasValidationErrors}
variant="outline"
className="border-[#3a3a3a] bg-transparent hover:bg-[#2a2a2a] text-white px-4"
>
{publishing ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Publishing...
</>
) : (
<>
<Rocket className="w-4 h-4 mr-2" />
Publish
</>
)}
</Button>
)}
{/* More options dropdown */}
<DropdownMenu>

View file

@ -8,6 +8,7 @@ interface WorkflowContextType {
documents?: DocumentResponseSchema[];
tools?: ToolResponse[];
recordings?: RecordingResponseSchema[];
readOnly?: boolean;
}
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);

View file

@ -165,7 +165,7 @@ interface UseWorkflowStateProps {
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string }; // Minimal user type needed
user: { id: string; email?: string } | null;
}
export const useWorkflowState = ({
@ -300,7 +300,7 @@ export const useWorkflowState = ({
// Validate workflow function
const validateWorkflow = useCallback(async () => {
if (!user) return;
if (!user?.id) return;
try {
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
@ -342,7 +342,7 @@ export const useWorkflowState = ({
if (response.data.is_valid === false && response.data.errors) {
const errors = response.data.errors;
errors.forEach((error) => {
errors.forEach((error: WorkflowError) => {
if (error.kind === 'node' && error.id) {
markNodeAsInvalid(error.id, error.message);
} else if (error.kind === 'edge' && error.id) {
@ -355,14 +355,14 @@ export const useWorkflowState = ({
logger.info('Workflow is valid');
}
}
} catch (error) {
} catch (error: unknown) {
logger.error(`Unexpected validation error: ${error}`);
}
}, [workflowId, user, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
// Save workflow function
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
if (!user || !rfInstance.current) return;
// Save workflow function. Returns version info from the API response.
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true): Promise<{ versionNumber?: number; versionStatus?: string } | undefined> => {
if (!user?.id || !rfInstance.current) return;
// Read nodes/edges from the Zustand store (synchronously up-to-date)
// and viewport from the ReactFlow instance to build the flow object.
// This avoids a race condition where rfInstance.toObject() may return
@ -370,8 +370,9 @@ export const useWorkflowState = ({
const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState();
const viewport = rfInstance.current.getViewport();
const flow = { nodes: currentNodes, edges: currentEdges, viewport };
let result: { versionNumber?: number; versionStatus?: string } | undefined;
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
@ -381,12 +382,19 @@ export const useWorkflowState = ({
},
});
setIsDirty(false);
if (response.data) {
result = {
versionNumber: response.data.version_number ?? undefined,
versionStatus: response.data.version_status ?? undefined,
};
}
} catch (error) {
logger.error(`Error saving workflow: ${error}`);
}
// Validate after saving
await validateWorkflow();
return result;
}, [workflowId, workflowName, setIsDirty, user, validateWorkflow]);
// Set up keyboard shortcut for save (Cmd/Ctrl + S)
@ -394,7 +402,9 @@ export const useWorkflowState = ({
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
saveWorkflow();
if (useWorkflowStore.getState().isDirty) {
saveWorkflow();
}
}
};
@ -439,7 +449,7 @@ export const useWorkflowState = ({
);
const onRun = async (mode: string) => {
if (!user) return;
if (!user?.id) return;
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
@ -455,7 +465,7 @@ export const useWorkflowState = ({
// Save template context variables
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
if (!user) return;
if (!user?.id) return;
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
@ -477,12 +487,12 @@ export const useWorkflowState = ({
// Save workflow configurations
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
if (!user) return;
if (!user?.id) return;
// Preserve the current dictionary when saving other configurations
const currentDictionary = useWorkflowStore.getState().dictionary;
const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary };
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
@ -492,6 +502,22 @@ export const useWorkflowState = ({
workflow_configurations: configurationsWithDictionary as Record<string, unknown>,
},
});
if (response.error) {
const detail = (response.error as { detail?: unknown }).detail;
let msg = 'Failed to save workflow configurations';
if (typeof detail === 'string') {
msg = detail;
} else if (Array.isArray(detail)) {
msg = detail
.map((e: { model?: string; message?: string; msg?: string }) =>
e.model && e.message ? `${e.model}: ${e.message}` : (e.msg || JSON.stringify(e))
)
.join('\n');
}
throw new Error(msg);
}
setWorkflowConfigurations(configurationsWithDictionary);
// Set name directly in the store to avoid setWorkflowName which marks isDirty: true
useWorkflowStore.setState({ workflowName: newWorkflowName });
@ -550,6 +576,7 @@ export const useWorkflowState = ({
workflowConfigurations,
dictionary,
setNodes,
setEdges,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,

View file

@ -82,6 +82,8 @@ export default function WorkflowDetailPage() {
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
initialVersionNumber={workflow.version_number ?? null}
initialVersionStatus={workflow.version_status ?? null}
user={stableUser}
/>
) : null;

View file

@ -526,7 +526,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnCredentialsRef.current.ttl}s`);
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`);
} else if (turnResponse.response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
logger.info('TURN server not configured, using STUN only');

View file

@ -0,0 +1,926 @@
"use client";
import { ArrowLeft, BookA, Brain, ExternalLink, Mic, PhoneOff, Rocket, Settings, Trash2Icon, Variable } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import SpinLoader from "@/components/SpinLoader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
type AmbientNoiseConfiguration,
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
DEFAULT_WORKFLOW_CONFIGURATIONS,
type TurnStopStrategy,
type VoicemailDetectionConfiguration,
type WorkflowConfigurations,
} from "@/types/workflow-configurations";
import { EmbedDialog } from "../components/EmbedDialog";
import { RecordingsDialog } from "../components/RecordingsDialog";
import { useWorkflowState } from "../hooks/useWorkflowState";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
enabled: false,
volume: 0.3,
};
const DEFAULT_VOICEMAIL_SYSTEM_PROMPT = `You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text.
HUMAN ANSWERED - LIVE CONVERSATION (respond "CONVERSATION"):
- Personal greetings: "Hello?", "Hi", "Yeah?", "John speaking"
- Interactive responses: "Who is this?", "What do you want?", "Can I help you?"
- Conversational tone expecting back-and-forth dialogue
- Questions directed at the caller: "Hello? Anyone there?"
- Informal responses: "Yep", "What's up?", "Speaking"
- Natural, spontaneous speech patterns
- Immediate acknowledgment of the call
VOICEMAIL SYSTEM (respond "VOICEMAIL"):
- Automated voicemail greetings: "Hi, you've reached [name], please leave a message"
- Phone carrier messages: "The number you have dialed is not in service", "Please leave a message", "All circuits are busy"
- Professional voicemail: "This is [name], I'm not available right now"
- Instructions about leaving messages: "leave a message", "leave your name and number"
- References to callback or messaging: "call me back", "I'll get back to you"
- Carrier system messages: "mailbox is full", "has not been set up"
- Business hours messages: "our office is currently closed"
Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's voicemail/recording.`;
// Sidebar navigation items
const NAV_ITEMS = [
{ id: "general", label: "General", icon: Settings },
{ id: "models", label: "Model Overrides", icon: Brain },
{ id: "variables", label: "Template Variables", icon: Variable },
{ id: "dictionary", label: "Dictionary", icon: BookA },
{ id: "voicemail", label: "Voicemail Detection", icon: PhoneOff },
{ id: "recordings", label: "Recordings", icon: Mic },
{ id: "deployment", label: "Deployment", icon: Rocket },
];
// ---------------------------------------------------------------------------
// Section: General
// ---------------------------------------------------------------------------
function GeneralSection({
workflowConfigurations,
workflowName,
onSave,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const [name, setName] = useState(workflowName);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG,
);
const [maxCallDuration, setMaxCallDuration] = useState(workflowConfigurations.max_call_duration || 600);
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState(workflowConfigurations.max_user_idle_timeout || 10);
const [smartTurnStopSecs, setSmartTurnStopSecs] = useState(workflowConfigurations.smart_turn_stop_secs || 2);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations.turn_stop_strategy || "transcription",
);
const [contextCompactionEnabled, setContextCompactionEnabled] = useState(
workflowConfigurations.context_compaction_enabled ?? false,
);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(
{
...workflowConfigurations,
ambient_noise_configuration: ambientNoiseConfig,
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout,
smart_turn_stop_secs: smartTurnStopSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
},
name,
);
} catch (error) {
console.error("Failed to save general settings:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="general">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
General
</CardTitle>
<CardDescription>Agent name, call behavior, and turn detection.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.general} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Agent Name */}
<div className="space-y-2">
<Label htmlFor="workflow_name" className="text-sm font-medium">Agent Name</Label>
<Input
id="workflow_name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter Agent name"
/>
</div>
<Separator />
{/* Ambient Noise */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Ambient Noise</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Add background office ambient noise to make the conversation sound more natural.
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="ambient-noise-enabled" className="text-sm">Use Ambient Noise</Label>
<Switch
id="ambient-noise-enabled"
checked={ambientNoiseConfig.enabled}
onCheckedChange={(checked) =>
setAmbientNoiseConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
{ambientNoiseConfig.enabled && (
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">Volume</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value }));
}}
/>
</div>
)}
</div>
<Separator />
{/* Turn Detection */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Turn Detection</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure how the agent detects when the user has finished speaking.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="turn_stop_strategy" className="text-xs">Detection Strategy</Label>
<Select
value={turnStopStrategy}
onValueChange={(value: TurnStopStrategy) => setTurnStopStrategy(value)}
>
<SelectTrigger id="turn_stop_strategy">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="transcription">Transcription-based</SelectItem>
<SelectItem value="turn_analyzer">Smart Turn Analyzer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{turnStopStrategy === "transcription"
? "Best for short responses (1-2 word statements). Ends turn when transcription indicates completion."
: "Best for longer responses with natural pauses. Uses ML model to detect end of turn."}
</p>
</div>
{turnStopStrategy === "turn_analyzer" && (
<div className="space-y-2">
<Label htmlFor="smart_turn_stop_secs" className="text-xs">
Incomplete Turn Timeout (seconds)
</Label>
<Input
id="smart_turn_stop_secs"
type="number"
step="0.5"
min="0.5"
max="10"
value={smartTurnStopSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.5) setSmartTurnStopSecs(value);
}}
/>
<p className="text-xs text-muted-foreground">
Max silence duration before ending an incomplete turn. Default: 2 seconds
</p>
</div>
)}
</div>
<Separator />
{/* Context Compaction */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Context Compaction</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Automatically summarize conversation context when transitioning between nodes.
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="context-compaction-enabled" className="text-sm">
Enable Context Compaction
</Label>
<Switch
id="context-compaction-enabled"
checked={contextCompactionEnabled}
onCheckedChange={setContextCompactionEnabled}
/>
</div>
</div>
<Separator />
{/* Call Management */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Call Management</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure call duration limits and idle timeout settings.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max_call_duration" className="text-xs">Max Call Duration (seconds)</Label>
<Input
id="max_call_duration"
type="number"
min="1"
value={maxCallDuration}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) setMaxCallDuration(value);
}}
/>
<p className="text-xs text-muted-foreground">Default: 600 (10 minutes)</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_user_idle_timeout" className="text-xs">
Max User Idle Timeout (seconds)
</Label>
<Input
id="max_user_idle_timeout"
type="number"
min="1"
value={maxUserIdleTimeout}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) setMaxUserIdleTimeout(value);
}}
/>
<p className="text-xs text-muted-foreground">Default: 10 seconds</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save General Settings"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Template Variables
// ---------------------------------------------------------------------------
function TemplateVariablesSection({
templateContextVariables,
onSave,
}: {
templateContextVariables: Record<string, string>;
onSave: (variables: Record<string, string>) => Promise<void>;
}) {
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
const handleAdd = () => {
if (newKey && newValue) {
setContextVars((prev) => ({ ...prev, [newKey]: newValue }));
}
setNewKey("");
setNewValue("");
};
const handleRemove = (key: string) => {
setContextVars((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const handleSave = async () => {
setIsSaving(true);
try {
let varsToSave = contextVars;
if (newKey && newValue) {
varsToSave = { ...varsToSave, [newKey]: newValue };
}
await onSave(varsToSave);
} catch (error) {
console.error("Failed to save variables:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="variables">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Variable className="h-4 w-4" />
Template Variables
</CardTitle>
<CardDescription>
Variables available in workflow prompts via {`{{variable_name}}`} syntax for testing the workflow.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.templateVariables} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing Variables */}
{Object.entries(contextVars).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(contextVars).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 rounded-md border p-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{key}</div>
<div className="text-xs text-muted-foreground truncate">{value}</div>
</div>
<Button size="sm" variant="ghost" onClick={() => handleRemove(key)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Label htmlFor="var-key" className="text-xs">Key</Label>
<Input
id="var-key"
placeholder="Enter variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor="var-value" className="text-xs">Value</Label>
<Input
id="var-value"
placeholder="Enter variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
/>
</div>
</div>
<Button size="sm" onClick={handleAdd} disabled={!newKey || !newValue}>
Add Variable
</Button>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Variables"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Dictionary
// ---------------------------------------------------------------------------
function DictionarySection({
dictionary,
onSave,
}: {
dictionary: string;
onSave: (dictionary: string) => Promise<void>;
}) {
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(dictionaryValue);
} catch (error) {
console.error("Failed to save dictionary:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="dictionary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookA className="h-4 w-4" />
Dictionary
</CardTitle>
<CardDescription>
Add words the agent should actively listen for &mdash; company jargon, names,
industry terms. May incur extra cost depending on provider.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="Enter words separated by comma (e.g. billing department, tretinoin)"
value={dictionaryValue}
onChange={(e) => setDictionaryValue(e.target.value)}
rows={4}
className="resize-none"
/>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Dictionary"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section: Voicemail Detection
// ---------------------------------------------------------------------------
function VoicemailSection({
workflowConfigurations,
workflowName,
onSave,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const getConfig = (): VoicemailDetectionConfiguration => ({
...DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
...workflowConfigurations.voicemail_detection,
});
const [enabled, setEnabled] = useState(getConfig().enabled);
const [useWorkflowLlm, setUseWorkflowLlm] = useState(getConfig().use_workflow_llm);
const [provider, setProvider] = useState(getConfig().provider || "openai");
const [model, setModel] = useState(getConfig().model || "gpt-4.1");
const [apiKey, setApiKey] = useState(getConfig().api_key || "");
const [systemPrompt, setSystemPrompt] = useState(getConfig().system_prompt || DEFAULT_VOICEMAIL_SYSTEM_PROMPT);
const [longSpeechTimeout, setLongSpeechTimeout] = useState(getConfig().long_speech_timeout);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
const voicemailConfig: VoicemailDetectionConfiguration = {
enabled,
use_workflow_llm: useWorkflowLlm,
provider: useWorkflowLlm ? undefined : provider,
model: useWorkflowLlm ? undefined : model,
api_key: useWorkflowLlm ? undefined : apiKey,
system_prompt:
systemPrompt && systemPrompt !== DEFAULT_VOICEMAIL_SYSTEM_PROMPT ? systemPrompt : undefined,
long_speech_timeout: longSpeechTimeout,
};
await onSave(
{ ...workflowConfigurations, voicemail_detection: voicemailConfig },
workflowName,
);
} catch (error) {
console.error("Failed to save voicemail settings:", error);
} finally {
setIsSaving(false);
}
};
return (
<Card id="voicemail">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<PhoneOff className="h-4 w-4" />
Voicemail Detection
</CardTitle>
<CardDescription>
Automatically detect and end calls when a voicemail system is reached.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
<Switch id="voicemail-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="voicemail-enabled">Enable Voicemail Detection</Label>
</div>
{enabled && (
<>
{/* LLM Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
<Switch
id="voicemail-use-workflow-llm"
checked={useWorkflowLlm}
onCheckedChange={setUseWorkflowLlm}
/>
<Label htmlFor="voicemail-use-workflow-llm">Use Workflow LLM</Label>
<Label className="ml-2 text-xs text-muted-foreground">
Use the LLM configured in your account settings.
</Label>
</div>
{!useWorkflowLlm && (
<LLMConfigSelector
provider={provider}
onProviderChange={setProvider}
model={model}
onModelChange={setModel}
apiKey={apiKey}
onApiKeyChange={setApiKey}
/>
)}
</div>
{/* System Prompt */}
<div className="space-y-2">
<Label>System Prompt</Label>
<p className="text-xs text-muted-foreground">
The LLM must respond with either &quot;CONVERSATION&quot; or &quot;VOICEMAIL&quot;.
</p>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[200px] font-mono text-xs"
/>
</div>
{/* Timing */}
<div className="space-y-2 rounded-md border bg-muted/10 p-3">
<Label className="font-medium">Timing</Label>
<div className="space-y-2">
<Label className="text-sm">Speech Cutoff (seconds)</Label>
<p className="text-xs text-muted-foreground">
Trigger classification early if first turn speech exceeds this duration.
</p>
<Input
type="number"
step="0.5"
min="1"
max="30"
value={longSpeechTimeout}
onChange={(e) => setLongSpeechTimeout(parseFloat(e.target.value) || 8.0)}
/>
</div>
</div>
</>
)}
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Voicemail Settings"}
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Page wrapper — handles auth & data fetching, then mounts the content
// component only when everything is loaded. This avoids useWorkflowState
// running with empty initial values and overwriting the Zustand store.
// ---------------------------------------------------------------------------
export default function WorkflowSettingsPage() {
const params = useParams();
const { user, redirectToLogin, loading: authLoading } = useAuth();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !user) {
redirectToLogin();
}
}, [authLoading, user, redirectToLogin]);
useEffect(() => {
const fetchWorkflow = async () => {
if (!user) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(params.workflowId) },
});
setWorkflow(response.data);
} catch (err) {
setError("Failed to fetch workflow");
logger.error(`Error fetching workflow settings: ${err}`);
} finally {
setLoading(false);
}
};
if (user) fetchWorkflow();
}, [params.workflowId, user]);
if (loading || authLoading) return <SpinLoader />;
if (error || !workflow) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-lg text-destructive">{error || "Workflow not found"}</div>
</div>
);
}
if (!user) return null;
return <WorkflowSettingsContent workflow={workflow} user={user} />;
}
// ---------------------------------------------------------------------------
// Content — only mounts once the workflow API response is available, so
// useWorkflowState always initialises with real data.
// ---------------------------------------------------------------------------
function WorkflowSettingsContent({
workflow,
user,
}: {
workflow: WorkflowResponse;
user: { id: string; email?: string };
}) {
const router = useRouter();
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState("general");
const workflowId = workflow.id;
const initialFlow = useMemo(
() => ({
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 0 },
}),
[workflow],
);
const initialTemplateContextVariables = useMemo(
() => (workflow.template_context_variables as Record<string, string>) || {},
[workflow],
);
const initialWorkflowConfigurations = useMemo(
() => (workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS,
[workflow],
);
const {
workflowName,
workflowConfigurations,
templateContextVariables,
dictionary,
saveWorkflowConfigurations,
saveTemplateContextVariables,
saveDictionary,
} = useWorkflowState({
initialWorkflowName: workflow.name,
workflowId,
initialFlow,
initialTemplateContextVariables,
initialWorkflowConfigurations,
user,
});
// Intersection observer for active sidebar link
useEffect(() => {
const ids = NAV_ITEMS.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
break;
}
}
},
{ rootMargin: "-20% 0px -60% 0px" },
);
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
// Sections are gated on configurations being present in the store.
// After mount, initializeWorkflow runs in a useEffect — the first render
// may still have stale store data, but the next tick corrects it.
const dataReady = !!workflowConfigurations;
return (
<div className="min-h-screen">
{/* Sticky header */}
<header className="sticky top-0 z-10 flex items-center gap-3 border-b bg-background/95 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p className="text-xs text-muted-foreground">Workflow Settings</p>
<h1 className="text-sm font-semibold">{workflowName || workflow.name}</h1>
</div>
</header>
{/* Main + right nav */}
<div className="mx-auto flex max-w-5xl gap-8 px-6 py-8">
{/* Sections */}
<div className="min-w-0 flex-1 space-y-8">
{dataReady && (
<>
{/* General */}
<GeneralSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName || workflow.name}
onSave={saveWorkflowConfigurations}
/>
{/* Model Overrides */}
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
Override global model settings for this workflow. Toggle individual services to
customize.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={async (config) => {
await saveWorkflowConfigurations(
{
...workflowConfigurations,
model_overrides:
config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
}}
/>
</CardContent>
</Card>
{/* Template Variables */}
<TemplateVariablesSection
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
{/* Dictionary */}
<DictionarySection dictionary={dictionary} onSave={saveDictionary} />
{/* Voicemail Detection */}
<VoicemailSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>
{/* Recordings (dialog trigger) */}
<Card id="recordings">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mic className="h-4 w-4" />
Recordings
</CardTitle>
<CardDescription>
Upload or record audio for hybrid prompts. Use{" "}
<code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to
insert them.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.recordings} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Button variant="outline" onClick={() => setIsRecordingsDialogOpen(true)}>
Manage Recordings
</Button>
</CardFooter>
</Card>
{/* Deployment (dialog trigger) */}
<Card id="deployment">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Rocket className="h-4 w-4" />
Deployment
</CardTitle>
<CardDescription>
Generate and manage the embed configuration to deploy this workflow on external
websites.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.deployment} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Button variant="outline" onClick={() => setIsEmbedDialogOpen(true)}>
Configure Embed
</Button>
</CardFooter>
</Card>
</>
)}
</div>
{/* ---- Right-side sticky nav ---- */}
<nav className="hidden w-44 shrink-0 lg:block">
<div className="sticky top-20 space-y-1">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
On this page
</p>
{NAV_ITEMS.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
activeSection === item.id
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{item.label}
</a>
))}
</div>
</nav>
</div>
{/* Dialogs for complex sections */}
<RecordingsDialog
open={isRecordingsDialogOpen}
onOpenChange={setIsRecordingsDialogOpen}
workflowId={workflowId}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName || workflow.name}
/>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import type { WorkflowListResponse } from '@/client/types.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
@ -46,12 +47,12 @@ async function WorkflowList() {
// Separate active and archived workflows
const activeWorkflows = allWorkflowData
.filter(w => w.status === 'active')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
.filter((w: WorkflowListResponse) => w.status === 'active')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const archivedWorkflows = allWorkflowData
.filter(w => w.status === 'archived')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
.filter((w: WorkflowListResponse) => w.status === 'archived')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return (
<>

View file

@ -1,9 +1,9 @@
// 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 ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
@ -13,8 +13,6 @@ import type { ClientOptions } from './types.gen';
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'https://app.dograh.com'
})));
export const client = createClient(createClientConfig(createConfig<ClientOptions2>({ baseUrl: 'https://app.dograh.com' })));

View file

@ -0,0 +1,298 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts: resolvedOpts, url };
};
const request: Client['request'] = async (options) => {
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};

View file

@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View file

@ -0,0 +1,214 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>
extends
Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onRequest'
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View file

@ -0,0 +1,316 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View file

@ -0,0 +1,41 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View file

@ -0,0 +1,82 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View file

@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View file

@ -0,0 +1,171 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};

View file

@ -0,0 +1,117 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View file

@ -0,0 +1,243 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View file

@ -0,0 +1,104 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
};

View file

@ -0,0 +1,140 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,648 +1,10 @@
"use client";
import { Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelector } from "@/components/VoiceSelector";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import { useUserConfig } from "@/context/UserConfigContext";
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
interface SchemaProperty {
type?: string;
default?: string | number | boolean;
enum?: string[];
examples?: string[];
model_options?: Record<string, string[]>;
allow_custom_input?: boolean;
$ref?: string;
description?: string;
format?: string;
}
interface ProviderSchema {
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
[key: string]: unknown;
}
interface FormValues {
[key: string]: string | number | boolean;
}
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
{ key: "stt", label: "Transcriber" },
{ key: "embeddings", label: "Embedding" },
];
const REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "realtime", label: "Realtime Model" },
{ key: "embeddings", label: "Embedding" },
];
// Display names for Sarvam voices
const VOICE_DISPLAY_NAMES: Record<string, string> = {
"anushka": "Anushka (Female)",
"manisha": "Manisha (Female)",
"vidya": "Vidya (Female)",
"arya": "Arya (Female)",
"abhilash": "Abhilash (Male)",
"karun": "Karun (Male)",
"hitesh": "Hitesh (Male)",
};
export default function ServiceConfiguration() {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isRealtime, setIsRealtime] = useState(false);
const { userConfig, saveUserConfig } = useUserConfig();
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
llm: {},
tts: {},
stt: {},
embeddings: {},
realtime: {},
});
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
llm: "",
tts: "",
stt: "",
embeddings: "",
realtime: "",
});
const [apiKeys, setApiKeys] = useState<Record<ServiceSegment, string[]>>({
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
realtime: [""],
});
const [isCustomInput, setIsCustomInput] = useState<Record<string, boolean>>({});
const {
register,
handleSubmit,
formState: { },
reset,
getValues,
setValue,
watch
} = useForm();
useEffect(() => {
const fetchConfigurations = async () => {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (response.data) {
const data = response.data as Record<string, unknown>;
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>,
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
realtime: (data.realtime || {}) as Record<string, ProviderSchema>,
});
// Restore realtime toggle from saved config
const configData = userConfig as Record<string, unknown> | null;
if (configData?.is_realtime) {
setIsRealtime(true);
}
} else {
console.error("Failed to fetch configurations");
return;
}
const defaultValues: Record<string, string | number | boolean> = {};
const selectedProviders: Record<ServiceSegment, string> = {
llm: response.data.default_providers.llm,
tts: response.data.default_providers.tts,
stt: response.data.default_providers.stt,
embeddings: response.data.default_providers.embeddings,
realtime: "",
};
// Set default realtime provider from schema keys
const data = response.data as Record<string, unknown>;
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
const realtimeProviderKeys = Object.keys(realtimeSchemas);
if (realtimeProviderKeys.length > 0) {
selectedProviders.realtime = realtimeProviderKeys[0];
}
const loadedApiKeys: Record<ServiceSegment, string[]> = {
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
realtime: [""],
};
const setServicePropertyValues = (service: ServiceSegment) => {
// For realtime, read from userConfig.realtime; for others, read from userConfig[service]
const configSource = service === "realtime"
? (userConfig as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
: userConfig?.[service as "llm" | "tts" | "stt" | "embeddings"];
const schemaSource = service === "realtime"
? realtimeSchemas
: response.data[service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
if (configSource?.provider) {
Object.entries(configSource).forEach(([field, value]) => {
if (field === "api_key") {
if (Array.isArray(value)) {
loadedApiKeys[service] = (value as string[]).length > 0 ? value as string[] : [""];
} else {
loadedApiKeys[service] = value ? [value as string] : [""];
}
} else if (field !== "provider") {
defaultValues[`${service}_${field}`] = value as string | number | boolean;
}
});
selectedProviders[service] = configSource.provider as string;
// Fill in schema defaults for fields not present in config
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
const key = `${service}_${field}`;
if (field !== "provider" && field !== "api_key" && schema.default !== undefined && !(key in defaultValues)) {
defaultValues[key] = schema.default;
}
});
}
} else {
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
if (field !== "provider" && schema.default !== undefined) {
defaultValues[`${service}_${field}`] = schema.default;
}
});
}
}
}
setServicePropertyValues("llm");
setServicePropertyValues("tts");
setServicePropertyValues("stt");
setServicePropertyValues("embeddings");
setServicePropertyValues("realtime");
// Detect saved values that are not in suggested options (custom value)
const detectedCustomInput: Record<string, boolean> = {};
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
const provider = selectedProviders[service];
const providerSchema = allSchemas[service]?.[provider];
if (!providerSchema) return;
const configSource = service === "realtime"
? (userConfig as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
: userConfig?.[service as "llm" | "tts" | "stt" | "embeddings"];
Object.entries(providerSchema.properties).forEach(([field, schema]) => {
const actualSchema = (schema as SchemaProperty).$ref && providerSchema.$defs
? providerSchema.$defs[(schema as SchemaProperty).$ref!.split('/').pop() || '']
: schema as SchemaProperty;
if (!actualSchema?.allow_custom_input || !actualSchema?.examples) return;
const savedValue = configSource?.[field] as string | undefined;
if (savedValue && !actualSchema.examples.includes(savedValue)) {
detectedCustomInput[`${service}_${field}`] = true;
}
});
});
// IMPORTANT: Reset form values BEFORE changing providers
// Otherwise, Radix Select sees old values that don't match new provider's enum
// and calls onValueChange('') to clear "invalid" values
reset(defaultValues);
setApiKeys(loadedApiKeys);
setServiceProviders(selectedProviders);
setIsCustomInput(detectedCustomInput);
};
fetchConfigurations();
}, [reset, userConfig]);
// Reset voice when TTS model changes if the provider has model-dependent voice options
const ttsModel = watch("tts_model");
useEffect(() => {
const voiceSchema = schemas?.tts?.[serviceProviders.tts]?.properties?.voice;
const modelOptions = voiceSchema?.model_options;
if (!modelOptions || !ttsModel) return;
const validVoices = modelOptions[ttsModel as string];
const currentVoice = getValues("tts_voice") as string;
if (validVoices && currentVoice && !validVoices.includes(currentVoice)) {
setValue("tts_voice", validVoices[0], { shouldDirty: true });
}
}, [ttsModel, serviceProviders.tts, setValue, getValues, schemas]);
// Reset language when STT model changes if the provider has model-dependent language options
const sttModel = watch("stt_model");
useEffect(() => {
const languageSchema = schemas?.stt?.[serviceProviders.stt]?.properties?.language;
const modelOptions = languageSchema?.model_options;
if (!modelOptions || !sttModel) return;
const validLanguages = modelOptions[sttModel as string];
const currentLanguage = getValues("stt_language") as string;
if (validLanguages && currentLanguage && !validLanguages.includes(currentLanguage)) {
setValue("stt_language", validLanguages[0], { shouldDirty: true });
}
}, [sttModel, serviceProviders.stt, setValue, getValues, schemas]);
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
if (!providerName) {
return;
}
const currentValues = getValues();
const preservedValues: Record<string, string | number | boolean> = {};
// Preserve values from other services
Object.keys(currentValues).forEach(key => {
if (!key.startsWith(`${service}_`)) {
preservedValues[key] = currentValues[key];
}
});
// Set default values from schema
if (schemas?.[service]?.[providerName]) {
const providerSchema = schemas[service][providerName];
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
if (field !== "provider" && schema.default !== undefined) {
preservedValues[`${service}_${field}`] = schema.default;
}
});
}
preservedValues[`${service}_provider`] = providerName;
reset(preservedValues);
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
setApiKeys(prev => ({ ...prev, [service]: [""] }));
// Reset custom input toggles when provider changes
setIsCustomInput(prev => {
const next = { ...prev };
Object.keys(next).forEach(key => {
if (key.startsWith(`${service}_`)) delete next[key];
});
return next;
});
}
const onSubmit = async (data: FormValues) => {
setApiError(null);
setIsSaving(true);
// Collect non-empty API keys per service
const getServiceApiKeys = (service: ServiceSegment): string[] =>
apiKeys[service].map(k => k.trim()).filter(k => k.length > 0);
// Build service configs from form data
const buildServiceConfig = (service: ServiceSegment) => {
const config: Record<string, string | number | string[]> = {
provider: serviceProviders[service],
};
const keys = getServiceApiKeys(service);
if (keys.length > 0) {
config.api_key = keys;
}
// Add all form fields for this service
Object.entries(data).forEach(([property, value]) => {
if (!property.startsWith(`${service}_`)) return;
const field = property.slice(service.length + 1);
if (field === "api_key" || field === "provider") return;
config[field] = value as string | number;
});
return config;
};
// Always save all configs so switching modes preserves everything
const saveConfig: Record<string, unknown> = {
llm: buildServiceConfig("llm"),
tts: buildServiceConfig("tts"),
stt: buildServiceConfig("stt"),
is_realtime: isRealtime,
};
// Save realtime config if provider is set
if (serviceProviders.realtime) {
saveConfig.realtime = buildServiceConfig("realtime");
}
// Only include embeddings if user has configured it (has api_key)
const embeddingsKeys = getServiceApiKeys("embeddings");
if (embeddingsKeys.length > 0) {
saveConfig.embeddings = buildServiceConfig("embeddings");
}
try {
await saveUserConfig(saveConfig);
setApiError(null);
} catch (error: unknown) {
if (error instanceof Error) {
setApiError(error.message);
} else {
setApiError('An unknown error occurred');
}
} finally {
setIsSaving(false);
}
};
const getConfigFields = (service: ServiceSegment): string[] => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
if (!providerSchema) return [];
// Find all config fields (not provider, not api_key)
const fields = Object.keys(providerSchema.properties).filter(
field => field !== "provider" && field !== "api_key"
);
return fields;
};
const renderServiceFields = (service: ServiceSegment) => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
const configFields = getConfigFields(service);
return (
<div className="space-y-6">
{/* Provider and first config field in one row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={currentProvider}
onValueChange={(providerName) => {
handleProviderChange(service, providerName);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProvider && providerSchema && configFields[0] && (
<div className="space-y-2">
<Label className="capitalize">{configFields[0].replace(/_/g, ' ')}</Label>
{renderField(service, configFields[0], providerSchema)}
</div>
)}
</div>
{/* Additional config fields (like voice for TTS) */}
{currentProvider && providerSchema && configFields.length > 1 && (
<div className="grid grid-cols-2 gap-4">
{configFields.slice(1).map((field) => (
<div key={field} className="space-y-2">
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
{renderField(service, field, providerSchema)}
</div>
))}
</div>
)}
{/* API Key(s) */}
{currentProvider && providerSchema && providerSchema.properties.api_key && (
<div className="space-y-2">
<Label>API Key(s)</Label>
{apiKeys[service].map((key, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
placeholder="Enter API key"
value={key}
onChange={(e) => {
const newKeys = [...apiKeys[service]];
newKeys[index] = e.target.value;
setApiKeys(prev => ({ ...prev, [service]: newKeys }));
}}
/>
{apiKeys[service].length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: prev[service].filter((_, i) => i !== index),
}));
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: [...prev[service], ""],
}));
}}
>
<Plus className="h-4 w-4 mr-1" /> Add API Key
</Button>
</div>
)}
</div>
);
};
const renderField = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
const schema = providerSchema.properties[field];
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
// VoiceSelector for TTS voice fields without predefined options or manual input flag
if (service === "tts" && field === "voice" && !actualSchema?.allow_custom_input) {
const hasVoiceOptions = actualSchema?.enum || actualSchema?.examples;
if (!hasVoiceOptions) {
return (
<VoiceSelector
provider={serviceProviders.tts}
value={watch(`${service}_${field}`) as string || ""}
onChange={(voiceId) => {
setValue(`${service}_${field}`, voiceId, { shouldDirty: true });
}}
model={watch("tts_model") as string || undefined}
/>
);
}
}
// Generic allow_custom_input handler for any field (model, voice with options, etc.)
if (actualSchema?.allow_custom_input && actualSchema?.examples) {
const fieldKey = `${service}_${field}`;
const currentValue = watch(fieldKey) as string || "";
const options = actualSchema.examples;
if (isCustomInput[fieldKey]) {
return (
<div className="space-y-2">
<Input
type="text"
placeholder={`Enter ${field}`}
value={currentValue}
onChange={(e) => {
setValue(fieldKey, e.target.value, { shouldDirty: true });
}}
/>
<div className="flex items-center space-x-2">
<Checkbox
id={`custom-input-${fieldKey}`}
checked={true}
onCheckedChange={(checked) => {
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
if (!checked && options.length > 0) {
setValue(fieldKey, options[0], { shouldDirty: true });
}
}}
/>
<Label htmlFor={`custom-input-${fieldKey}`} className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
</div>
);
}
return (
<div className="space-y-2">
<Select
value={currentValue}
onValueChange={(value) => {
if (!value) return;
setValue(fieldKey, value, { shouldDirty: true });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{options.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Checkbox
id={`custom-input-${fieldKey}-dropdown`}
checked={false}
onCheckedChange={(checked) => {
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
}}
/>
<Label htmlFor={`custom-input-${fieldKey}-dropdown`} className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
</div>
);
}
// Handle fields with enum or examples (dropdown options)
let dropdownOptions = actualSchema?.enum || actualSchema?.examples;
// Use model-dependent options when available (e.g., Sarvam voices per model)
if (actualSchema?.model_options) {
const modelValue = watch(`${service}_model`) as string;
if (modelValue && actualSchema.model_options[modelValue]) {
dropdownOptions = actualSchema.model_options[modelValue];
}
}
if (dropdownOptions && dropdownOptions.length > 0) {
// Use friendly display names for language and voice fields
const getDisplayName = (value: string) => {
if (field === "language") {
return LANGUAGE_DISPLAY_NAMES[value] || value;
}
if (field === "voice") {
return VOICE_DISPLAY_NAMES[value] || value.charAt(0).toUpperCase() + value.slice(1);
}
return value;
};
return (
<Select
value={watch(`${service}_${field}`) as string || ""}
onValueChange={(value) => {
// Ignore empty string - Radix Select sometimes calls onValueChange('')
// when options change, even if current value is valid
if (!value) return;
setValue(`${service}_${field}`, value, { shouldDirty: true });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{dropdownOptions.map((value: string) => (
<SelectItem key={value} value={value}>
{getDisplayName(value)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
{...(actualSchema?.type === "number" && { step: "any" })}
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
// Embeddings is optional, so don't require its fields
required: service !== "embeddings" && providerSchema.required?.includes(field),
valueAsNumber: actualSchema?.type === "number"
})}
/>
);
};
const visibleTabs = isRealtime ? REALTIME_TABS : STANDARD_TABS;
const defaultTab = isRealtime ? "realtime" : "llm";
const { saveUserConfig } = useUserConfig();
return (
<div className="w-full max-w-2xl mx-auto">
@ -653,50 +15,12 @@ export default function ServiceConfiguration() {
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Realtime toggle */}
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
<div>
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
Realtime Mode
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Uses a single speech-to-speech model (no separate STT/TTS)
</p>
</div>
<Switch
id="realtime-toggle"
checked={isRealtime}
onCheckedChange={setIsRealtime}
/>
</div>
<Card>
<CardContent className="pt-6">
<Tabs key={defaultTab} defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full mb-6" style={{ gridTemplateColumns: `repeat(${visibleTabs.length}, 1fr)` }}>
{visibleTabs.map(({ key, label }) => (
<TabsTrigger key={key} value={key}>
{label}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.map(({ key }) => (
<TabsContent key={key} value={key} className="mt-0">
{renderServiceFields(key)}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
{apiError && <p className="text-red-500 mt-4">{apiError}</p>}
<Button type="submit" className="w-full mt-6" disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</form>
<ServiceConfigurationForm
mode="global"
onSave={async (config) => {
await saveUserConfig(config);
}}
/>
</div>
);
}

View file

@ -0,0 +1,799 @@
"use client";
import { Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelector } from "@/components/VoiceSelector";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
import type { ModelOverrides } from "@/types/workflow-configurations";
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
interface SchemaProperty {
type?: string;
default?: string | number | boolean;
enum?: string[];
examples?: string[];
model_options?: Record<string, string[]>;
allow_custom_input?: boolean;
$ref?: string;
description?: string;
format?: string;
}
interface ProviderSchema {
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
[key: string]: unknown;
}
interface FormValues {
[key: string]: string | number | boolean;
}
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
{ key: "stt", label: "Transcriber" },
{ key: "embeddings", label: "Embedding" },
];
const REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "realtime", label: "Realtime Model" },
{ key: "embeddings", label: "Embedding" },
];
const OVERRIDE_STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
{ key: "stt", label: "Transcriber" },
];
const OVERRIDE_REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "realtime", label: "Realtime Model" },
];
// Display names for Sarvam voices
const VOICE_DISPLAY_NAMES: Record<string, string> = {
"anushka": "Anushka (Female)",
"manisha": "Manisha (Female)",
"vidya": "Vidya (Female)",
"arya": "Arya (Female)",
"abhilash": "Abhilash (Male)",
"karun": "Karun (Male)",
"hitesh": "Hitesh (Male)",
};
export interface ServiceConfigurationFormProps {
mode: 'global' | 'override';
currentOverrides?: ModelOverrides;
onSave: (config: Record<string, unknown>) => Promise<void>;
/** Text for the submit button. Defaults to "Save Configuration". */
submitLabel?: string;
}
function getGlobalSummary(config: Record<string, unknown> | null | undefined): string {
if (!config) return "Not configured";
const provider = config.provider as string | undefined;
const model = config.model as string | undefined;
if (!provider) return "Not configured";
return model ? `${provider} / ${model}` : provider;
}
export function ServiceConfigurationForm({
mode,
currentOverrides,
onSave,
submitLabel,
}: ServiceConfigurationFormProps) {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isRealtime, setIsRealtime] = useState(false);
const { userConfig } = useUserConfig();
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
llm: {},
tts: {},
stt: {},
embeddings: {},
realtime: {},
});
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
llm: "",
tts: "",
stt: "",
embeddings: "",
realtime: "",
});
const [apiKeys, setApiKeys] = useState<Record<ServiceSegment, string[]>>({
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
realtime: [""],
});
const [isCustomInput, setIsCustomInput] = useState<Record<string, boolean>>({});
// Override-specific state: which services have the override toggle enabled
const [enabledOverrides, setEnabledOverrides] = useState<Record<string, boolean>>({
llm: false,
tts: false,
stt: false,
realtime: false,
});
const {
register,
handleSubmit,
formState: { },
reset,
getValues,
setValue,
watch
} = useForm();
// Build effective config source: overlay overrides onto global config
const configSource = useMemo(() => {
if (mode === 'global' || !currentOverrides) return userConfig;
// Merge overrides onto global config for form initialization
const merged = { ...userConfig } as Record<string, unknown>;
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
for (const svc of overrideServices) {
if (svc === "is_realtime") continue;
const overrideVal = currentOverrides[svc];
if (overrideVal && typeof overrideVal === "object") {
const globalVal = (userConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
merged[svc] = { ...globalVal, ...overrideVal };
}
}
if (currentOverrides.is_realtime !== undefined) {
merged.is_realtime = currentOverrides.is_realtime;
}
return merged as typeof userConfig;
}, [mode, userConfig, currentOverrides]);
useEffect(() => {
const fetchConfigurations = async () => {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (!response.data) {
console.error("Failed to fetch configurations");
return;
}
const data = response.data as Record<string, unknown>;
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
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>,
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
realtime: realtimeSchemas,
});
// Restore realtime toggle
const configData = configSource as Record<string, unknown> | null;
if (configData?.is_realtime) {
setIsRealtime(true);
}
const defaultValues: Record<string, string | number | boolean> = {};
const selectedProviders: Record<ServiceSegment, string> = {
llm: response.data.default_providers.llm,
tts: response.data.default_providers.tts,
stt: response.data.default_providers.stt,
embeddings: response.data.default_providers.embeddings,
realtime: "",
};
const realtimeProviderKeys = Object.keys(realtimeSchemas);
if (realtimeProviderKeys.length > 0) {
selectedProviders.realtime = realtimeProviderKeys[0];
}
const loadedApiKeys: Record<ServiceSegment, string[]> = {
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
realtime: [""],
};
const setServicePropertyValues = (service: ServiceSegment) => {
const src = service === "realtime"
? (configSource as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
: (configSource as Record<string, unknown> | null)?.[service] as Record<string, unknown> | undefined;
const schemaSource = service === "realtime"
? realtimeSchemas
: response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
if (src?.provider) {
Object.entries(src).forEach(([field, value]) => {
if (field === "api_key") {
if (mode === 'override') {
// In override mode, only load API keys from the override itself
const overrideVal = currentOverrides?.[service as keyof ModelOverrides];
const overrideApiKey = overrideVal && typeof overrideVal === "object"
? (overrideVal as Record<string, unknown>).api_key
: undefined;
if (overrideApiKey) {
loadedApiKeys[service] = Array.isArray(overrideApiKey)
? overrideApiKey as string[]
: [overrideApiKey as string];
} else {
loadedApiKeys[service] = [""];
}
} else {
if (Array.isArray(value)) {
loadedApiKeys[service] = (value as string[]).length > 0 ? value as string[] : [""];
} else {
loadedApiKeys[service] = value ? [value as string] : [""];
}
}
} else if (field !== "provider") {
defaultValues[`${service}_${field}`] = value as string | number | boolean;
}
});
selectedProviders[service] = src.provider as string;
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
const key = `${service}_${field}`;
if (field !== "provider" && field !== "api_key" && schema.default !== undefined && !(key in defaultValues)) {
defaultValues[key] = schema.default;
}
});
}
} else {
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
if (field !== "provider" && schema.default !== undefined) {
defaultValues[`${service}_${field}`] = schema.default;
}
});
}
}
};
setServicePropertyValues("llm");
setServicePropertyValues("tts");
setServicePropertyValues("stt");
setServicePropertyValues("embeddings");
setServicePropertyValues("realtime");
// Detect custom inputs
const detectedCustomInput: Record<string, boolean> = {};
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
const provider = selectedProviders[service];
const providerSchema = allSchemas[service]?.[provider];
if (!providerSchema) return;
const src = service === "realtime"
? (configSource as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
: (configSource as Record<string, unknown> | null)?.[service] as Record<string, unknown> | undefined;
Object.entries(providerSchema.properties).forEach(([field, schema]) => {
const actualSchema = (schema as SchemaProperty).$ref && providerSchema.$defs
? providerSchema.$defs[(schema as SchemaProperty).$ref!.split('/').pop() || '']
: schema as SchemaProperty;
if (!actualSchema?.allow_custom_input || !actualSchema?.examples) return;
const savedValue = src?.[field] as string | undefined;
if (savedValue && !actualSchema.examples.includes(savedValue)) {
detectedCustomInput[`${service}_${field}`] = true;
}
});
});
// Initialize override toggles
if (mode === 'override') {
setEnabledOverrides({
llm: !!currentOverrides?.llm,
tts: !!currentOverrides?.tts,
stt: !!currentOverrides?.stt,
realtime: !!currentOverrides?.realtime,
});
}
reset(defaultValues);
setApiKeys(loadedApiKeys);
setServiceProviders(selectedProviders);
setIsCustomInput(detectedCustomInput);
};
fetchConfigurations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reset, configSource]);
// Reset voice when TTS model changes if the provider has model-dependent voice options
const ttsModel = watch("tts_model");
useEffect(() => {
const voiceSchema = schemas?.tts?.[serviceProviders.tts]?.properties?.voice;
const modelOptions = voiceSchema?.model_options;
if (!modelOptions || !ttsModel) return;
const validVoices = modelOptions[ttsModel as string];
const currentVoice = getValues("tts_voice") as string;
if (validVoices && currentVoice && !validVoices.includes(currentVoice)) {
setValue("tts_voice", validVoices[0], { shouldDirty: true });
}
}, [ttsModel, serviceProviders.tts, setValue, getValues, schemas]);
// Reset language when STT model changes if the provider has model-dependent language options
const sttModel = watch("stt_model");
useEffect(() => {
const languageSchema = schemas?.stt?.[serviceProviders.stt]?.properties?.language;
const modelOptions = languageSchema?.model_options;
if (!modelOptions || !sttModel) return;
const validLanguages = modelOptions[sttModel as string];
const currentLanguage = getValues("stt_language") as string;
if (validLanguages && currentLanguage && !validLanguages.includes(currentLanguage)) {
setValue("stt_language", validLanguages[0], { shouldDirty: true });
}
}, [sttModel, serviceProviders.stt, setValue, getValues, schemas]);
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
if (!providerName) return;
const currentValues = getValues();
const preservedValues: Record<string, string | number | boolean> = {};
Object.keys(currentValues).forEach(key => {
if (!key.startsWith(`${service}_`)) {
preservedValues[key] = currentValues[key];
}
});
if (schemas?.[service]?.[providerName]) {
const providerSchema = schemas[service][providerName];
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
if (field !== "provider" && schema.default !== undefined) {
preservedValues[`${service}_${field}`] = schema.default;
}
});
}
preservedValues[`${service}_provider`] = providerName;
reset(preservedValues);
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
setApiKeys(prev => ({ ...prev, [service]: [""] }));
setIsCustomInput(prev => {
const next = { ...prev };
Object.keys(next).forEach(key => {
if (key.startsWith(`${service}_`)) delete next[key];
});
return next;
});
};
const buildServiceConfig = (service: ServiceSegment, data: FormValues) => {
const config: Record<string, string | number | string[]> = {
provider: serviceProviders[service],
};
const keys = apiKeys[service].map(k => k.trim()).filter(k => k.length > 0);
if (keys.length > 0) {
config.api_key = mode === 'override' ? keys[0] : keys;
}
Object.entries(data).forEach(([property, value]) => {
if (!property.startsWith(`${service}_`)) return;
const field = property.slice(service.length + 1);
if (field === "api_key" || field === "provider") return;
config[field] = value as string | number;
});
return config;
};
const onSubmit = async (data: FormValues) => {
setApiError(null);
setIsSaving(true);
try {
if (mode === 'override') {
// Build model_overrides for enabled services only
const modelOverrides: Record<string, unknown> = {};
const services = isRealtime ? ["realtime"] : ["llm", "tts", "stt"];
for (const svc of services) {
if (enabledOverrides[svc]) {
modelOverrides[svc] = buildServiceConfig(svc as ServiceSegment, data);
}
}
// Include is_realtime if it differs from global
const globalIsRealtime = !!(userConfig as Record<string, unknown> | null)?.is_realtime;
if (isRealtime !== globalIsRealtime) {
modelOverrides.is_realtime = isRealtime;
}
await onSave({
model_overrides: Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined,
});
} else {
// Global mode: save all services
const saveConfig: Record<string, unknown> = {
llm: buildServiceConfig("llm", data),
tts: buildServiceConfig("tts", data),
stt: buildServiceConfig("stt", data),
is_realtime: isRealtime,
};
if (serviceProviders.realtime) {
saveConfig.realtime = buildServiceConfig("realtime", data);
}
const embeddingsKeys = apiKeys.embeddings.map(k => k.trim()).filter(k => k.length > 0);
if (embeddingsKeys.length > 0) {
saveConfig.embeddings = buildServiceConfig("embeddings", data);
}
await onSave(saveConfig);
}
setApiError(null);
} catch (error: unknown) {
if (error instanceof Error) {
setApiError(error.message);
} else {
setApiError('An unknown error occurred');
}
} finally {
setIsSaving(false);
}
};
const getConfigFields = (service: ServiceSegment): string[] => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
if (!providerSchema) return [];
return Object.keys(providerSchema.properties).filter(
field => field !== "provider" && field !== "api_key"
);
};
const renderServiceFields = (service: ServiceSegment) => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
const configFields = getConfigFields(service);
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={currentProvider}
onValueChange={(providerName) => {
handleProviderChange(service, providerName);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProvider && providerSchema && configFields[0] && (
<div className="space-y-2">
<Label className="capitalize">{configFields[0].replace(/_/g, ' ')}</Label>
{renderField(service, configFields[0], providerSchema)}
</div>
)}
</div>
{currentProvider && providerSchema && configFields.length > 1 && (
<div className="grid grid-cols-2 gap-4">
{configFields.slice(1).map((field) => (
<div key={field} className="space-y-2">
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
{renderField(service, field, providerSchema)}
</div>
))}
</div>
)}
{currentProvider && providerSchema && providerSchema.properties.api_key && (
<div className="space-y-2">
<Label>{mode === 'override' ? 'API Key (leave empty to use global)' : 'API Key(s)'}</Label>
{apiKeys[service].map((key, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
placeholder="Enter API key"
value={key}
onChange={(e) => {
const newKeys = [...apiKeys[service]];
newKeys[index] = e.target.value;
setApiKeys(prev => ({ ...prev, [service]: newKeys }));
}}
/>
{apiKeys[service].length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: prev[service].filter((_, i) => i !== index),
}));
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
{mode !== 'override' && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: [...prev[service], ""],
}));
}}
>
<Plus className="h-4 w-4 mr-1" /> Add API Key
</Button>
)}
</div>
)}
</div>
);
};
const renderField = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
const schema = providerSchema.properties[field];
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
if (service === "tts" && field === "voice" && !actualSchema?.allow_custom_input) {
const hasVoiceOptions = actualSchema?.enum || actualSchema?.examples;
if (!hasVoiceOptions) {
return (
<VoiceSelector
provider={serviceProviders.tts}
value={watch(`${service}_${field}`) as string || ""}
onChange={(voiceId) => {
setValue(`${service}_${field}`, voiceId, { shouldDirty: true });
}}
model={watch("tts_model") as string || undefined}
/>
);
}
}
if (actualSchema?.allow_custom_input && actualSchema?.examples) {
const fieldKey = `${service}_${field}`;
const currentValue = watch(fieldKey) as string || "";
const options = actualSchema.examples;
if (isCustomInput[fieldKey]) {
return (
<div className="space-y-2">
<Input
type="text"
placeholder={`Enter ${field}`}
value={currentValue}
onChange={(e) => {
setValue(fieldKey, e.target.value, { shouldDirty: true });
}}
/>
<div className="flex items-center space-x-2">
<Checkbox
id={`custom-input-${fieldKey}`}
checked={true}
onCheckedChange={(checked) => {
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
if (!checked && options.length > 0) {
setValue(fieldKey, options[0], { shouldDirty: true });
}
}}
/>
<Label htmlFor={`custom-input-${fieldKey}`} className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
</div>
);
}
return (
<div className="space-y-2">
<Select
value={currentValue}
onValueChange={(value) => {
if (!value) return;
setValue(fieldKey, value, { shouldDirty: true });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{options.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Checkbox
id={`custom-input-${fieldKey}-dropdown`}
checked={false}
onCheckedChange={(checked) => {
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
}}
/>
<Label htmlFor={`custom-input-${fieldKey}-dropdown`} className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
</div>
);
}
let dropdownOptions = actualSchema?.enum || actualSchema?.examples;
if (actualSchema?.model_options) {
const modelValue = watch(`${service}_model`) as string;
if (modelValue && actualSchema.model_options[modelValue]) {
dropdownOptions = actualSchema.model_options[modelValue];
}
}
if (dropdownOptions && dropdownOptions.length > 0) {
const getDisplayName = (value: string) => {
if (field === "language") {
return LANGUAGE_DISPLAY_NAMES[value] || value;
}
if (field === "voice") {
return VOICE_DISPLAY_NAMES[value] || value.charAt(0).toUpperCase() + value.slice(1);
}
return value;
};
return (
<Select
value={watch(`${service}_${field}`) as string || ""}
onValueChange={(value) => {
if (!value) return;
setValue(`${service}_${field}`, value, { shouldDirty: true });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{dropdownOptions.map((value: string) => (
<SelectItem key={value} value={value}>
{getDisplayName(value)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
{...(actualSchema?.type === "number" && { step: "any" })}
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
required: service !== "embeddings" && providerSchema.required?.includes(field),
valueAsNumber: actualSchema?.type === "number"
})}
/>
);
};
const handleOverrideToggle = (service: string, enabled: boolean) => {
setEnabledOverrides(prev => ({ ...prev, [service]: enabled }));
};
const renderOverrideToggle = (service: ServiceSegment, label: string) => {
const globalVal = (userConfig as Record<string, unknown> | null)?.[service] as Record<string, unknown> | null | undefined;
const isEnabled = enabledOverrides[service];
return (
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/20 mb-4">
<div className="space-y-0.5">
<Label htmlFor={`override-${service}`} className="text-sm cursor-pointer font-medium">
Override {label}
</Label>
{!isEnabled && (
<p className="text-xs text-muted-foreground">
Using global: {getGlobalSummary(globalVal)}
</p>
)}
</div>
<Switch
id={`override-${service}`}
checked={isEnabled}
onCheckedChange={(checked) => handleOverrideToggle(service, checked)}
/>
</div>
);
};
const getVisibleTabs = () => {
if (mode === 'override') {
return isRealtime ? OVERRIDE_REALTIME_TABS : OVERRIDE_STANDARD_TABS;
}
return isRealtime ? REALTIME_TABS : STANDARD_TABS;
};
const visibleTabs = getVisibleTabs();
const defaultTab = isRealtime ? "realtime" : "llm";
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Realtime toggle */}
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
<div>
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
Realtime Mode
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Uses a single speech-to-speech model (no separate STT/TTS)
</p>
</div>
<Switch
id="realtime-toggle"
checked={isRealtime}
onCheckedChange={setIsRealtime}
/>
</div>
<Card>
<CardContent className="pt-6">
<Tabs key={defaultTab} defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full mb-6" style={{ gridTemplateColumns: `repeat(${visibleTabs.length}, 1fr)` }}>
{visibleTabs.map(({ key, label }) => (
<TabsTrigger key={key} value={key}>
{label}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.map(({ key, label }) => (
<TabsContent key={key} value={key} className="mt-0">
{mode === 'override' && renderOverrideToggle(key, label)}
{(mode === 'global' || enabledOverrides[key]) && renderServiceFields(key)}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
{apiError && <p className="text-red-500 mt-4">{apiError}</p>}
<Button type="submit" className="w-full mt-6" disabled={isSaving}>
{isSaving ? "Saving..." : (submitLabel || "Save Configuration")}
</Button>
</form>
);
}

View file

@ -2,7 +2,7 @@ import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPa
import { AlertCircle, Pencil } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { useWorkflow, useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -23,6 +23,7 @@ interface EdgeDetailsDialogProps {
}
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
const readOnly = useWorkflowOptional()?.readOnly ?? false;
const [condition, setCondition] = useState(data?.condition ?? '');
const [label, setLabel] = useState(data?.label ?? '');
const [transitionSpeech, setTransitionSpeech] = useState(data?.transition_speech ?? '');
@ -43,7 +44,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
useEffect(() => {
if (!open) return;
if (!open || readOnly) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
@ -55,7 +56,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleSave]);
}, [open, readOnly, handleSave]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -115,7 +116,9 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
<Button onClick={handleSave} disabled={readOnly}>
{readOnly ? "Read Only" : "Save"}
</Button>
</div>
</DialogFooter>
</DialogContent>

View file

@ -1,6 +1,7 @@
import { AlertCircle, ExternalLink } from "lucide-react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import {
AlertDialog,
@ -38,6 +39,7 @@ export const NodeEditDialog = ({
isDirty = false,
documentationUrl,
}: NodeEditDialogProps) => {
const readOnly = useWorkflowOptional()?.readOnly ?? false;
const [showDiscardAlert, setShowDiscardAlert] = useState(false);
const handleClose = () => onOpenChange(false);
@ -66,7 +68,7 @@ export const NodeEditDialog = ({
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
useEffect(() => {
if (!open) return;
if (!open || readOnly) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
@ -78,7 +80,7 @@ export const NodeEditDialog = ({
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleSave]);
}, [open, readOnly, handleSave]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
@ -128,7 +130,9 @@ export const NodeEditDialog = ({
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
<Button onClick={handleSave} disabled={readOnly}>
{readOnly ? "Read Only" : "Save"}
</Button>
</div>
</DialogFooter>
</DialogContent>

View file

@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { listTestSessionsApiV1LooptalkTestSessionsGet } from '@/client/sdk.gen';
import type { TestSessionResponse } from '@/client/types.gen';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
@ -32,7 +33,7 @@ export function LoopTalkTestSessionsList({ status }: LoopTalkTestSessionsListPro
});
// Transform API response to match UI types
const transformedSessions = (response.data || []).map(session => ({
const transformedSessions = (response.data || []).map((session: TestSessionResponse) => ({
id: session.id,
name: session.name,
description: '', // API doesn't return description

View file

@ -18,6 +18,15 @@ export const KNOWLEDGE_BASE_DOC_URL = `${DOCS_BASE}/voice-agent/knowledge-base`;
export const PRE_CALL_DATA_FETCH_DOC_URL = `${DOCS_BASE}/voice-agent/pre-call-data-fetch`;
export const SETTINGS_DOCUMENTATION_URLS: Record<string, string> = {
general: `${DOCS_BASE}/voice-agent/editing-a-workflow`,
modelOverrides: `${DOCS_BASE}/configurations/inference-providers`,
templateVariables: `${DOCS_BASE}/voice-agent/template-variables`,
recordings: `${DOCS_BASE}/voice-agent/pre-recorded-audio`,
deployment: `${DOCS_BASE}/deployment/web-widget`,
};
export const TOOL_DOCUMENTATION_URLS: Record<string, string> = {
http_api: `${DOCS_BASE}/voice-agent/tools/http-api`,
end_call: `${DOCS_BASE}/voice-agent/tools/end-call`,

View file

@ -1,5 +1,4 @@
import type { Client } from '@hey-api/client-fetch';
import type { Client } from '@/client/client';
import type { CreateClientConfig } from '@/client/client.gen';
export const createClientConfig: CreateClientConfig = (config) => {

View file

@ -28,6 +28,36 @@ export const DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION: VoicemailDetectionConfig
long_speech_timeout: 8.0,
};
export interface ModelOverrides {
llm?: {
provider?: string;
model?: string;
api_key?: string;
[key: string]: unknown;
};
tts?: {
provider?: string;
model?: string;
voice?: string;
api_key?: string;
[key: string]: unknown;
};
stt?: {
provider?: string;
model?: string;
api_key?: string;
[key: string]: unknown;
};
realtime?: {
provider?: string;
model?: string;
voice?: string;
api_key?: string;
[key: string]: unknown;
};
is_realtime?: boolean;
}
export interface WorkflowConfigurations {
vad_configuration?: VADConfiguration;
ambient_noise_configuration: AmbientNoiseConfiguration;
@ -38,6 +68,7 @@ export interface WorkflowConfigurations {
dictionary?: string; // Comma-separated words for voice agent to listen for
voicemail_detection?: VoicemailDetectionConfiguration;
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
[key: string]: unknown; // Allow additional properties for future configurations
}