mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
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:
parent
f5fa9ce717
commit
38d1d928b7
62 changed files with 10158 additions and 3131 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface WorkflowContextType {
|
|||
documents?: DocumentResponseSchema[];
|
||||
tools?: ToolResponse[];
|
||||
recordings?: RecordingResponseSchema[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
926
ui/src/app/workflow/[workflowId]/settings/page.tsx
Normal file
926
ui/src/app/workflow/[workflowId]/settings/page.tsx
Normal 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 — 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 "CONVERSATION" or "VOICEMAIL".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue