diff --git a/apps/rowboat/app/actions/simulation_actions.ts b/apps/rowboat/app/actions/simulation_actions.ts new file mode 100644 index 00000000..91cd5db4 --- /dev/null +++ b/apps/rowboat/app/actions/simulation_actions.ts @@ -0,0 +1,84 @@ +'use server'; + +import { ObjectId } from "mongodb"; +import { scenariosCollection } from "@/app/lib/mongodb"; +import { z } from 'zod'; +import { projectAuthCheck } from "./project_actions"; +import { Scenario, type WithStringId } from "@/app/lib/types"; +import { SimulationScenarioData } from "@/app/lib/types"; + +export async function getScenarios(projectId: string): Promise>[]> { + await projectAuthCheck(projectId); + + const scenarios = await scenariosCollection.find({ projectId }).toArray(); + return scenarios.map(s => ({ + ...s, + _id: s._id.toString(), + })); +} + +export async function getScenario(projectId: string, scenarioId: string): Promise>> { + await projectAuthCheck(projectId); + + // fetch scenario + const scenario = await scenariosCollection.findOne({ + _id: new ObjectId(scenarioId), + projectId, + }); + if (!scenario) { + throw new Error('Scenario not found'); + } + const { _id, description, ...rest } = scenario; + return { + ...rest, + _id: _id.toString(), + scenario: description, + }; +} + +export async function createScenario(projectId: string, name: string, description: string): Promise { + await projectAuthCheck(projectId); + + const now = new Date().toISOString(); + const result = await scenariosCollection.insertOne({ + projectId, + name, + description, + lastUpdatedAt: now, + createdAt: now, + }); + + return result.insertedId.toString(); +} + +export async function updateScenario( + projectId: string, + scenarioId: string, + updates: { name?: string; description?: string } +): Promise { + await projectAuthCheck(projectId); + + const updateData: any = { + ...updates, + lastUpdatedAt: new Date().toISOString(), + }; + + await scenariosCollection.updateOne( + { + _id: new ObjectId(scenarioId), + projectId, + }, + { + $set: updateData, + } + ); +} + +export async function deleteScenario(projectId: string, scenarioId: string): Promise { + await projectAuthCheck(projectId); + + await scenariosCollection.deleteOne({ + _id: new ObjectId(scenarioId), + projectId, + }); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/menu.tsx b/apps/rowboat/app/projects/[projectId]/menu.tsx index f5e15944..1781a8ab 100644 --- a/apps/rowboat/app/projects/[projectId]/menu.tsx +++ b/apps/rowboat/app/projects/[projectId]/menu.tsx @@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"; import { Tooltip } from "@nextui-org/react"; import Link from "next/link"; import clsx from "clsx"; -import { DatabaseIcon, SettingsIcon, WorkflowIcon } from "lucide-react"; +import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon } from "lucide-react"; function NavLink({ href, label, icon, collapsed, selected = false }: { href: string, label: string, icon: React.ReactNode, collapsed: boolean, selected?: boolean }) { return } selected={pathname.startsWith(`/projects/${projectId}/workflow`)} /> + } + selected={pathname.startsWith(`/projects/${projectId}/simulation`)} + /> {useDataSources && Simulatebeta; @@ -28,6 +29,7 @@ export function App({ messageSubscriber?: (messages: z.infer[]) => void; }) { const searchParams = useSearchParams(); + const router = useRouter(); const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]); const [existingChatId, setExistingChatId] = useState(initialChatId); const [loadingChat, setLoadingChat] = useState(false); @@ -41,9 +43,42 @@ export function App({ systemMessage: defaultSystemMessage, }); - function handleSimulateButtonClick() { - setViewSimulationMenu(true); + const beginSimulation = useCallback((data: z.infer) => { + setExistingChatId(null); + setViewSimulationMenu(false); + setCounter(counter + 1); + setChat({ + projectId, + createdAt: new Date().toISOString(), + messages: [], + simulated: true, + simulationData: data, + }); + }, [counter, projectId]); + + useEffect(() => { + const scenarioId = localStorage.getItem('pendingScenarioId'); + if (scenarioId && projectId) { + console.log('Scenario Effect triggered:', { scenarioId, projectId }); + getScenario(projectId, scenarioId).then((scenario) => { + console.log('Scenario data received:', scenario); + beginSimulation(scenario as z.infer); + localStorage.removeItem('pendingScenarioId'); + }).catch(error => { + console.error('Error fetching scenario:', error); + localStorage.removeItem('pendingScenarioId'); + }); + } + }, [projectId, beginSimulation]); + + if (hidden) { + return <>; } + + function handleSimulateButtonClick() { + router.push(`/projects/${projectId}/simulation`); + } + function handleNewChatButtonClick() { setExistingChatId(null); setViewSimulationMenu(false); @@ -56,52 +91,38 @@ export function App({ systemMessage: defaultSystemMessage, }); } - function beginSimulation(data: z.infer) { - setExistingChatId(null); - setViewSimulationMenu(false); - setCounter(counter + 1); - setChat({ - projectId, - createdAt: new Date().toISOString(), - messages: [], - simulated: true, - simulationData: data, - }); - } - if (hidden) { - return <>; - } - - return : "Chat"} actions={[ - } - onClick={handleNewChatButtonClick} - > - New chat - , - !viewSimulationMenu && } - onClick={handleSimulateButtonClick} - > - Simulate - , - ]}> -
- {!viewSimulationMenu && loadingChat &&
- -
} - {!viewSimulationMenu && !loadingChat && } - {viewSimulationMenu && } -
-
; + return ( + : "Chat"} actions={[ + } + onClick={handleNewChatButtonClick} + > + New chat + , + } + onClick={handleSimulateButtonClick} + > + Simulate + , + ]}> +
+ {!viewSimulationMenu && loadingChat &&
+ +
} + {!viewSimulationMenu && !loadingChat && } + {viewSimulationMenu && } +
+
+ ); } diff --git a/apps/rowboat/app/projects/[projectId]/simulation/app.tsx b/apps/rowboat/app/projects/[projectId]/simulation/app.tsx new file mode 100644 index 00000000..6c7ad62b --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/simulation/app.tsx @@ -0,0 +1,514 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { PlusIcon, PencilIcon, XMarkIcon, DocumentDuplicateIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon } from '@heroicons/react/24/outline'; +import { useParams, useRouter } from 'next/navigation'; +import { + getScenarios, + createScenario, + updateScenario, + deleteScenario, +} from '@/app/actions/simulation_actions'; +import { Scenario, type WithStringId } from '@/app/lib/types'; +import { z } from 'zod'; + +type ScenarioType = WithStringId>; + +type SimulationResult = { + scenarioId: string; + scenarioName: string; + passed: boolean; + details: string; + scenario: ScenarioType; +}; + +type SimulationReport = { + totalScenarios: number; + passedScenarios: number; + failedScenarios: number; + results: SimulationResult[]; + timestamp: Date; +}; + +const dummySimulator = async (scenario: ScenarioType): Promise => { + await new Promise(resolve => setTimeout(resolve, 500)); + const passed = Math.random() > 0.5; + + return { + scenarioId: scenario._id, + scenarioName: scenario.name, + passed, + details: passed + ? "The bot successfully completed the conversation" + : "The bot could not handle the conversation", + scenario: scenario, + }; +}; + +export default function SimulationApp() { + const { projectId } = useParams(); + const router = useRouter(); + const [scenarios, setScenarios] = useState([]); + const [selectedScenario, setSelectedScenario] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [menuOpenScenarioId, setMenuOpenScenarioId] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [simulationReport, setSimulationReport] = useState(null); + const [expandedResults, setExpandedResults] = useState>(new Set()); + + // Load scenarios on mount + useEffect(() => { + if (!projectId) return; + getScenarios(projectId as string).then(setScenarios); + }, [projectId]); + + useEffect(() => { + if (menuOpenScenarioId) { + const closeMenu = () => setMenuOpenScenarioId(null); + window.addEventListener('click', closeMenu); + return () => window.removeEventListener('click', closeMenu); + } + }, [menuOpenScenarioId]); + + const createNewScenario = async () => { + if (!projectId) return; + const newScenarioId = await createScenario( + projectId as string, + 'New Scenario', + '' + ); + // Refresh scenarios list + const updatedScenarios = await getScenarios(projectId as string); + setScenarios(updatedScenarios); + const newScenario = updatedScenarios.find(s => s._id === newScenarioId); + if (newScenario) { + setSelectedScenario(newScenario); + setIsEditing(true); + } + }; + + const handleUpdateScenario = async (updatedScenario: ScenarioType) => { + if (!projectId) return; + await updateScenario( + projectId as string, + updatedScenario._id, + { + name: updatedScenario.name, + description: updatedScenario.description, + } + ); + // Refresh scenarios list + const updatedScenarios = await getScenarios(projectId as string); + setScenarios(updatedScenarios); + const refreshedScenario = updatedScenarios.find(s => s._id === updatedScenario._id); + if (refreshedScenario) { + setSelectedScenario(refreshedScenario); + } + setIsEditing(false); + }; + + const handleCloseScenario = () => { + setSelectedScenario(null); + setIsEditing(false); + }; + + const handleDeleteScenario = async (scenarioId: string) => { + if (!projectId) return; + await deleteScenario(projectId as string, scenarioId); + const updatedScenarios = await getScenarios(projectId as string); + setScenarios(updatedScenarios); + if (selectedScenario?._id === scenarioId) { + setSelectedScenario(null); + setIsEditing(false); + } + setMenuOpenScenarioId(null); + }; + + const runAllScenarios = async () => { + setIsRunning(true); + setSimulationReport(null); + + try { + const results: SimulationResult[] = []; + + // Run each scenario through the simulator + for (const scenario of scenarios) { + const result = await dummySimulator(scenario); + results.push(result); + } + + // Generate report + const passedScenarios = results.filter(r => r.passed).length; + const report: SimulationReport = { + totalScenarios: scenarios.length, + passedScenarios, + failedScenarios: scenarios.length - passedScenarios, + results, + timestamp: new Date(), + }; + + setSimulationReport(report); + } catch (error) { + console.error('Error running scenarios:', error); + } finally { + setIsRunning(false); + } + }; + + const runSingleScenario = (scenario: ScenarioType) => { + // Store scenario ID in localStorage instead of URL parameter + localStorage.setItem('pendingScenarioId', scenario._id); + // Navigate to the playground without query parameter + router.push(`/projects/${projectId}/workflow`); + setMenuOpenScenarioId(null); + }; + + return ( +
+ {/* Left sidebar */} +
+
+

Scenarios

+
+ +
+
+ +
+ {scenarios.map(scenario => ( +
+
setSelectedScenario(scenario)} + className="cursor-pointer flex-grow" + > + {scenario.name} +
+
+ + {menuOpenScenarioId === scenario._id && ( +
+
+ + +
+
+ )} +
+
+ ))} +
+
+ + {/* Main content */} +
+ {selectedScenario ? ( + isEditing ? ( + setIsEditing(false)} + /> + ) : ( + setIsEditing(true)} + onClose={handleCloseScenario} + /> + ) + ) : ( +
+ {simulationReport ? ( + <> +
+
+

Simulation Results

+
+ Run on {simulationReport.timestamp.toLocaleString()} +
+
+ +
+ +
+
+
Total Scenarios
+
{simulationReport.totalScenarios}
+
+
+
Passed
+
+ {simulationReport.passedScenarios} +
+
+
+
Failed
+
+ {simulationReport.failedScenarios} +
+
+
+ +
+

Detailed Results

+
+ {simulationReport.results.map((result) => ( +
+
{ + const newExpandedResults = new Set(expandedResults); + if (expandedResults.has(result.scenarioId)) { + newExpandedResults.delete(result.scenarioId); + } else { + newExpandedResults.add(result.scenarioId); + } + setExpandedResults(newExpandedResults); + }} + > +
+ + {result.scenarioName} +
+
+ {result.passed ? 'Passed' : 'Failed'} +
+
+ + {expandedResults.has(result.scenarioId) && ( +
+
+
+
+ Name +
+
{result.scenario.name}
+
+ +
+
+ Description +
+
{result.scenario.description}
+
+
+ +
+
+ Details +
+
{result.details}
+
+
+ )} +
+ ))} +
+
+ + ) : ( + <> +
+

Scenarios

+ +
+
+ Select a scenario or run all scenarios +
+ + )} +
+ )} +
+
+ ); +} + +function ScenarioViewer({ + scenario, + onEdit, + onClose, +}: { + scenario: ScenarioType; + onEdit: () => void; + onClose: () => void; +}) { + return ( +
+
+

{scenario.name}

+
+ + +
+
+
+
+
NAME
+
{scenario.name}
+
+ +
+ +
+
DESCRIPTION
+
{scenario.description}
+
+
+
+ ); +} + +function ScenarioEditor({ + scenario, + onSave, + onCancel, +}: { + scenario: ScenarioType; + onSave: (scenario: ScenarioType) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(scenario.name); + const [description, setDescription] = useState(scenario.description); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ + ...scenario, + name, + description, + }); + }; + + return ( +
+
+

Edit Scenario

+
+ + +
+
+
+
+
NAME
+ setName(e.target.value)} + className="mt-1 block w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 px-3 py-2" + /> +
+ +
+ +
+
DESCRIPTION
+