Add simulation page

This commit is contained in:
akhisud3195 2025-02-13 13:58:00 +05:30
parent e6ae7c965f
commit 1174182980
6 changed files with 612 additions and 1 deletions

View file

@ -0,0 +1,79 @@
'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";
export async function getScenarios(projectId: string): Promise<WithStringId<z.infer<typeof Scenario>>[]> {
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<WithStringId<z.infer<typeof Scenario>> | null> {
await projectAuthCheck(projectId);
const scenario = await scenariosCollection.findOne({
_id: new ObjectId(scenarioId),
projectId,
});
if (!scenario) return null;
return {
...scenario,
_id: scenario._id.toString(),
};
}
export async function createScenario(projectId: string, name: string, description: string): Promise<string> {
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<void> {
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<void> {
await projectAuthCheck(projectId);
await scenariosCollection.deleteOne({
_id: new ObjectId(scenarioId),
projectId,
});
}

View file

@ -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 <Link
@ -55,6 +55,13 @@ export default function Menu({
icon={<WorkflowIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/simulation`}
label="Simulation"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
/>
{useDataSources && <NavLink
href={`/projects/${projectId}/sources`}
label="Data sources"

View file

@ -0,0 +1,504 @@
'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<z.infer<typeof Scenario>>;
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<SimulationResult> => {
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<ScenarioType[]>([]);
const [selectedScenario, setSelectedScenario] = useState<ScenarioType | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [menuOpenScenarioId, setMenuOpenScenarioId] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [simulationReport, setSimulationReport] = useState<SimulationReport | null>(null);
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
// Load scenarios on mount
useEffect(() => {
if (!projectId) return;
getScenarios(projectId as string).then(setScenarios);
}, [projectId]);
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) => {
// Navigate to the workflow playground with the scenario
router.push(`/projects/${projectId}/workflow/playground?scenarioId=${scenario._id}`);
setMenuOpenScenarioId(null);
};
return (
<div className="flex h-screen">
{/* Left sidebar */}
<div className="w-64 border-r border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Scenarios</h2>
<div className="flex gap-2">
<button
onClick={createNewScenario}
className="p-2 rounded-full hover:bg-gray-100"
title="New Scenario"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="space-y-2">
{scenarios.map(scenario => (
<div
key={scenario._id}
className={`p-2 rounded flex justify-between items-center ${
selectedScenario?._id === scenario._id
? 'bg-blue-100'
: 'hover:bg-gray-100'
}`}
>
<div
onClick={() => setSelectedScenario(scenario)}
className="cursor-pointer flex-grow"
>
{scenario.name}
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpenScenarioId(menuOpenScenarioId === scenario._id ? null : scenario._id);
}}
className="p-1 rounded-full hover:bg-gray-200"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
</button>
{menuOpenScenarioId === scenario._id && (
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div className="py-1">
<button
onClick={(e) => {
e.stopPropagation();
runSingleScenario(scenario);
}}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full"
>
<PlayIcon className="h-4 w-4 mr-2" />
Run Scenario
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteScenario(scenario._id);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Scenario
</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Main content */}
<div className="flex-1 p-6 overflow-auto">
{selectedScenario ? (
isEditing ? (
<ScenarioEditor
scenario={selectedScenario}
onSave={handleUpdateScenario}
onCancel={() => setIsEditing(false)}
/>
) : (
<ScenarioViewer
scenario={selectedScenario}
onEdit={() => setIsEditing(true)}
onClose={handleCloseScenario}
/>
)
) : (
<div className="space-y-6">
{simulationReport ? (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">Simulation Results</h1>
<div className="text-sm text-gray-500 mt-1">
Run on {simulationReport.timestamp.toLocaleString()}
</div>
</div>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-gray-500">Total Scenarios</div>
<div className="text-2xl font-bold">{simulationReport.totalScenarios}</div>
</div>
<div className="bg-green-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-green-600">Passed</div>
<div className="text-2xl font-bold text-green-600">
{simulationReport.passedScenarios}
</div>
</div>
<div className="bg-red-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-red-600">Failed</div>
<div className="text-2xl font-bold text-red-600">
{simulationReport.failedScenarios}
</div>
</div>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Detailed Results</h2>
<div className="space-y-2">
{simulationReport.results.map((result) => (
<div
key={result.scenarioId}
className={`p-4 rounded-lg border ${
result.passed ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => {
const newExpandedResults = new Set(expandedResults);
if (expandedResults.has(result.scenarioId)) {
newExpandedResults.delete(result.scenarioId);
} else {
newExpandedResults.add(result.scenarioId);
}
setExpandedResults(newExpandedResults);
}}
>
<div className="font-medium flex items-center gap-2">
<ChevronRightIcon
className={`h-5 w-5 transform transition-transform ${
expandedResults.has(result.scenarioId) ? 'rotate-90' : ''
}`}
/>
{result.scenarioName}
</div>
<div
className={`px-2 py-1 rounded text-sm w-16 text-center ${
result.passed
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{result.passed ? 'Passed' : 'Failed'}
</div>
</div>
{expandedResults.has(result.scenarioId) && (
<div className="mt-4 pl-7 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Name
</div>
<div className="text-sm">{result.scenario.name}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Description
</div>
<div className="text-sm">{result.scenario.description}</div>
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Details
</div>
<div className="text-sm">{result.details}</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
</>
) : (
<>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Scenarios</h1>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
</div>
<div className="text-center text-gray-500 mt-10">
Select a scenario or run all scenarios
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
function ScenarioViewer({
scenario,
onEdit,
onClose,
}: {
scenario: ScenarioType;
onEdit: () => void;
onClose: () => void;
}) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">{scenario.name}</h1>
<div className="flex gap-2">
<button
onClick={onEdit}
className="p-2 rounded-full hover:bg-gray-100"
title="Edit"
>
<PencilIcon className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<div className="text-base">{scenario.name}</div>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<div className="text-base whitespace-pre-wrap">{scenario.description}</div>
</div>
</div>
</div>
);
}
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 (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Edit Scenario</h1>
<div className="flex gap-2">
<button
onClick={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
className="p-2 rounded-full hover:bg-gray-100"
title="Save"
>
<DocumentDuplicateIcon className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
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"
/>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,10 @@
import { Metadata } from "next";
import SimulationApp from "./app";
export const metadata: Metadata = {
title: "Project simulation",
};
export default function SimulationPage() {
return <SimulationApp />;
}

View file

@ -13,6 +13,7 @@
"@aws-sdk/client-s3": "^3.743.0",
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.1.1",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",
@ -2338,6 +2339,15 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",

View file

@ -18,6 +18,7 @@
"@aws-sdk/client-s3": "^3.743.0",
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.1.1",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",