diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts new file mode 100644 index 0000000000..9790aa1cec --- /dev/null +++ b/apps/sim/app/api/copilot/training/route.ts @@ -0,0 +1,103 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('CopilotTrainingAPI') + +// Schema for the request body +const TrainingDataSchema = z.object({ + title: z.string().min(1), + prompt: z.string().min(1), + input: z.any(), // Workflow state (start) + output: z.any(), // Workflow state (end) + operations: z.any(), +}) + +export async function POST(request: NextRequest) { + try { + // Check for required environment variables + const baseUrl = env.AGENT_INDEXER_URL + if (!baseUrl) { + logger.error('Missing AGENT_INDEXER_URL environment variable') + return NextResponse.json({ error: 'Agent indexer not configured' }, { status: 500 }) + } + + const apiKey = env.AGENT_INDEXER_API_KEY + if (!apiKey) { + logger.error('Missing AGENT_INDEXER_API_KEY environment variable') + return NextResponse.json( + { error: 'Agent indexer authentication not configured' }, + { status: 500 } + ) + } + + // Parse and validate request body + const body = await request.json() + const validationResult = TrainingDataSchema.safeParse(body) + + if (!validationResult.success) { + logger.warn('Invalid training data format', { errors: validationResult.error.errors }) + return NextResponse.json( + { + error: 'Invalid training data format', + details: validationResult.error.errors, + }, + { status: 400 } + ) + } + + const { title, prompt, input, output, operations } = validationResult.data + + logger.info('Sending training data to agent indexer', { + title, + operationsCount: operations.length, + }) + + const wrappedOperations = { + operations: operations, + } + + // Forward to agent indexer + const upstreamUrl = `${baseUrl}/operations/add` + const upstreamResponse = await fetch(upstreamUrl, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + title, + prompt, + input, + output, + operations: wrappedOperations, + }), + }) + + const responseData = await upstreamResponse.json() + + if (!upstreamResponse.ok) { + logger.error('Agent indexer rejected the data', { + status: upstreamResponse.status, + response: responseData, + }) + return NextResponse.json(responseData, { status: upstreamResponse.status }) + } + + logger.info('Successfully sent training data to agent indexer', { + title, + response: responseData, + }) + + return NextResponse.json(responseData) + } catch (error) { + logger.error('Failed to send training data to agent indexer', { error }) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Failed to send training data', + }, + { status: 502 } + ) + } +} diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index a5a2d72650..cba8148ad6 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -26,6 +26,8 @@ const SettingsSchema = z.object({ }) .optional(), billingUsageNotificationsEnabled: z.boolean().optional(), + showFloatingControls: z.boolean().optional(), + showTrainingControls: z.boolean().optional(), }) // Default settings values @@ -38,6 +40,8 @@ const defaultSettings = { telemetryEnabled: true, emailPreferences: {}, billingUsageNotificationsEnabled: true, + showFloatingControls: true, + showTrainingControls: false, } export async function GET() { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-controls.tsx new file mode 100644 index 0000000000..822fe50f63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-controls.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getEnv, isTruthy } from '@/lib/env' +import { useCopilotTrainingStore } from '@/stores/copilot-training/store' +import { useGeneralStore } from '@/stores/settings/general/store' +import { TrainingFloatingButton } from './training-floating-button' +import { TrainingModal } from './training-modal' + +/** + * Main training controls component that manages the training UI + * Only renders if COPILOT_TRAINING_ENABLED env var is set AND user has enabled it in settings + */ +export function TrainingControls() { + const [isEnvEnabled, setIsEnvEnabled] = useState(false) + const showTrainingControls = useGeneralStore((state) => state.showTrainingControls) + const { isTraining, showModal, toggleModal } = useCopilotTrainingStore() + + // Check environment variable on mount + useEffect(() => { + // Use getEnv to check if training is enabled + const trainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED')) + setIsEnvEnabled(trainingEnabled) + }, []) + + // Don't render if not enabled by env var OR user settings + if (!isEnvEnabled || !showTrainingControls) { + return null + } + + return ( + <> + {/* Floating button to start/stop training */} + + + {/* Modal for entering prompt and viewing dataset */} + {showModal && } + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-floating-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-floating-button.tsx new file mode 100644 index 0000000000..3ca1698835 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-floating-button.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Database, Pause } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { useCopilotTrainingStore } from '@/stores/copilot-training/store' + +interface TrainingFloatingButtonProps { + isTraining: boolean + onToggleModal: () => void +} + +/** + * Floating button positioned above the diff controls + * Shows training state and allows starting/stopping training + */ +export function TrainingFloatingButton({ isTraining, onToggleModal }: TrainingFloatingButtonProps) { + const { stopTraining } = useCopilotTrainingStore() + + const handleClick = () => { + if (isTraining) { + // Stop and save the training session + const dataset = stopTraining() + if (dataset) { + // Show a brief success indicator + const button = document.getElementById('training-button') + if (button) { + button.classList.add('animate-pulse') + setTimeout(() => button.classList.remove('animate-pulse'), 1000) + } + } + } else { + // Open modal to start new training + onToggleModal() + } + } + + return ( +
+ + + + + + {isTraining + ? 'Stop recording and save training dataset' + : 'Start recording workflow changes for training'} + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-modal.tsx new file mode 100644 index 0000000000..bd2f51e0c7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-modal.tsx @@ -0,0 +1,688 @@ +'use client' + +import { useState } from 'react' +import { + Check, + CheckCircle2, + ChevronDown, + Clipboard, + Download, + Eye, + Send, + Trash2, + X, + XCircle, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' +import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence' +import { useCopilotTrainingStore } from '@/stores/copilot-training/store' + +/** + * Modal for starting training sessions and viewing/exporting datasets + */ +export function TrainingModal() { + const { + isTraining, + currentTitle, + currentPrompt, + startSnapshot, + datasets, + showModal, + setPrompt, + startTraining, + cancelTraining, + toggleModal, + clearDatasets, + exportDatasets, + markDatasetSent, + } = useCopilotTrainingStore() + + const [localPrompt, setLocalPrompt] = useState(currentPrompt) + const [localTitle, setLocalTitle] = useState(currentTitle) + const [copiedId, setCopiedId] = useState(null) + const [viewingDataset, setViewingDataset] = useState(null) + const [expandedDataset, setExpandedDataset] = useState(null) + const [sendingDatasets, setSendingDatasets] = useState>(new Set()) + const [sendingAll, setSendingAll] = useState(false) + const [selectedDatasets, setSelectedDatasets] = useState>(new Set()) + const [sendingSelected, setSendingSelected] = useState(false) + const [sentDatasets, setSentDatasets] = useState>(new Set()) + const [failedDatasets, setFailedDatasets] = useState>(new Set()) + + const handleStart = () => { + if (localTitle.trim() && localPrompt.trim()) { + startTraining(localTitle, localPrompt) + setLocalTitle('') + setLocalPrompt('') + } + } + + const handleCopyDataset = (dataset: any) => { + const dataStr = JSON.stringify( + { + prompt: dataset.prompt, + startState: dataset.startState, + endState: dataset.endState, + editSequence: dataset.editSequence, + metadata: dataset.metadata, + }, + null, + 2 + ) + + navigator.clipboard.writeText(dataStr) + setCopiedId(dataset.id) + setTimeout(() => setCopiedId(null), 2000) + } + + const handleExportAll = () => { + const dataStr = exportDatasets() + const blob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `copilot-training-${new Date().toISOString().split('T')[0]}.json` + a.click() + URL.revokeObjectURL(url) + } + + const sendToIndexer = async (dataset: any) => { + try { + // Extract subblock values from the workflow states + const extractSubBlockValues = (state: any) => { + const subBlockValues: Record> = {} + + if (state.blocks) { + for (const [blockId, block] of Object.entries(state.blocks)) { + if ((block as any).subBlocks) { + const blockSubValues: Record = {} + for (const [subBlockId, subBlock] of Object.entries((block as any).subBlocks)) { + if ((subBlock as any).value !== undefined) { + blockSubValues[subBlockId] = (subBlock as any).value + } + } + if (Object.keys(blockSubValues).length > 0) { + subBlockValues[blockId] = blockSubValues + } + } + } + } + + return subBlockValues + } + + const startSubBlockValues = extractSubBlockValues(dataset.startState) + const endSubBlockValues = extractSubBlockValues(dataset.endState) + + // Convert both states to YAML in parallel + const [startYamlResponse, endYamlResponse] = await Promise.all([ + fetch('/api/workflows/yaml/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowState: dataset.startState, + subBlockValues: startSubBlockValues, + }), + }), + fetch('/api/workflows/yaml/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowState: dataset.endState, + subBlockValues: endSubBlockValues, + }), + }), + ]) + + if (!startYamlResponse.ok) { + throw new Error('Failed to convert start state to YAML') + } + if (!endYamlResponse.ok) { + throw new Error('Failed to convert end state to YAML') + } + + const [startResult, endResult] = await Promise.all([ + startYamlResponse.json(), + endYamlResponse.json(), + ]) + + // Now send to the indexer with YAML states + const response = await fetch('/api/copilot/training', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: dataset.title, + prompt: dataset.prompt, + input: startResult.yaml, // YAML string + output: endResult.yaml, // YAML string + operations: dataset.editSequence, + }), + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || 'Failed to send to indexer') + } + + return result + } catch (error) { + console.error('Failed to send dataset to indexer:', error) + throw error + } + } + + const handleSendOne = (dataset: any) => { + // Clear any previous status for this dataset + setSentDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + setFailedDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + + // Add to sending set + setSendingDatasets((prev) => new Set(prev).add(dataset.id)) + + // Fire and forget - handle async without blocking + sendToIndexer(dataset) + .then(() => { + // Remove from sending and mark as sent + setSendingDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + setSentDatasets((prev) => new Set(prev).add(dataset.id)) + // Persist sent marker in store + markDatasetSent(dataset.id) + // Clear success indicator after 5 seconds + setTimeout(() => { + setSentDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + }, 5000) + }) + .catch((error) => { + // Remove from sending and mark as failed + setSendingDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + setFailedDatasets((prev) => new Set(prev).add(dataset.id)) + // Clear failure indicator after 5 seconds + setTimeout(() => { + setFailedDatasets((prev) => { + const newSet = new Set(prev) + newSet.delete(dataset.id) + return newSet + }) + }, 5000) + }) + } + + const handleSendAll = async () => { + setSendingAll(true) + try { + const results = await Promise.allSettled(datasets.map((dataset) => sendToIndexer(dataset))) + + const successes = results.filter((r) => r.status === 'fulfilled') + const failures = results.filter((r) => r.status === 'rejected') + + // Mark successes and failures visually + const successfulIds = datasets + .filter((_, i) => results[i].status === 'fulfilled') + .map((d) => d.id) + const failedIds = datasets.filter((_, i) => results[i].status === 'rejected').map((d) => d.id) + + setSentDatasets((prev) => new Set([...prev, ...successfulIds])) + setFailedDatasets((prev) => new Set([...prev, ...failedIds])) + + // Persist sent markers for successes + successfulIds.forEach((id) => markDatasetSent(id)) + + // Auto-clear failure badges after 5s + if (failedIds.length > 0) { + setTimeout(() => { + setFailedDatasets((prev) => { + const newSet = new Set(prev) + failedIds.forEach((id) => newSet.delete(id)) + return newSet + }) + }, 5000) + } + } finally { + setSendingAll(false) + } + } + + const handleSendSelected = async () => { + if (selectedDatasets.size === 0) return + + setSendingSelected(true) + try { + const datasetsToSend = datasets.filter((d) => selectedDatasets.has(d.id)) + const results = await Promise.allSettled( + datasetsToSend.map((dataset) => sendToIndexer(dataset)) + ) + + const successfulIds = datasetsToSend + .filter((_, i) => results[i].status === 'fulfilled') + .map((d) => d.id) + const failedIds = datasetsToSend + .filter((_, i) => results[i].status === 'rejected') + .map((d) => d.id) + + setSentDatasets((prev) => new Set([...prev, ...successfulIds])) + setFailedDatasets((prev) => new Set([...prev, ...failedIds])) + successfulIds.forEach((id) => markDatasetSent(id)) + + // Remove successes from selection + setSelectedDatasets((prev) => { + const newSet = new Set(prev) + successfulIds.forEach((id) => newSet.delete(id)) + return newSet + }) + + // Auto-clear failure badges after 5s + if (failedIds.length > 0) { + setTimeout(() => { + setFailedDatasets((prev) => { + const newSet = new Set(prev) + failedIds.forEach((id) => newSet.delete(id)) + return newSet + }) + }, 5000) + } + } finally { + setSendingSelected(false) + } + } + + const toggleDatasetSelection = (datasetId: string) => { + const newSelection = new Set(selectedDatasets) + if (newSelection.has(datasetId)) { + newSelection.delete(datasetId) + } else { + newSelection.add(datasetId) + } + setSelectedDatasets(newSelection) + } + + const toggleSelectAll = () => { + if (selectedDatasets.size === datasets.length) { + setSelectedDatasets(new Set()) + } else { + setSelectedDatasets(new Set(datasets.map((d) => d.id))) + } + } + + return ( + + + + Copilot Training Dataset Builder + + Record workflow editing sessions to create training datasets for the copilot + + + + {isTraining && ( + <> +
+

+ Recording: {currentTitle} +

+

{currentPrompt}

+
+ + +
+
+ + {startSnapshot && ( +
+

Starting State

+

+ {Object.keys(startSnapshot.blocks).length} blocks, {startSnapshot.edges.length}{' '} + edges +

+
+ )} + + )} + + + + + New Session + + Datasets ({datasets.length}) + + + {/* New Training Session Tab */} + + {startSnapshot && ( +
+

Current Workflow State

+

+ {Object.keys(startSnapshot.blocks).length} blocks, {startSnapshot.edges.length}{' '} + edges +

+
+ )} + +
+ + setLocalTitle(e.target.value)} + /> +
+ +
+ +