mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Merge pull request #27 from rowboatlabs/simulation
Simulation --> Dev || Remove old scenarios code and add simulation runs from DB
This commit is contained in:
commit
1e13d659b7
12 changed files with 759 additions and 746 deletions
|
|
@ -1,53 +0,0 @@
|
|||
'use server';
|
||||
import { ObjectId } from "mongodb";
|
||||
import { scenariosCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import { WithStringId } from "../lib/types/types";
|
||||
import { Scenario } from "../lib/types/testing_types";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
|
||||
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 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,
|
||||
context: '', // Always empty string
|
||||
lastUpdatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
return result.insertedId.toString();
|
||||
}
|
||||
|
||||
export async function updateScenario(projectId: string, scenarioId: string, name: string, description: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await scenariosCollection.updateOne({
|
||||
"_id": new ObjectId(scenarioId),
|
||||
"projectId": projectId,
|
||||
}, {
|
||||
$set: {
|
||||
name,
|
||||
description,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScenario(projectId: string, scenarioId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await scenariosCollection.deleteOne({
|
||||
"_id": new ObjectId(scenarioId),
|
||||
"projectId": projectId,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
'use server';
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
import { scenariosCollection } from "../lib/mongodb";
|
||||
import { scenariosCollection, simulationRunsCollection, simulationResultsCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { type WithStringId } from "../lib/types/types";
|
||||
import { Scenario } from "../lib/types/testing_types";
|
||||
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../lib/types/testing_types";
|
||||
import { SimulationScenarioData } from "../lib/types/testing_types";
|
||||
|
||||
export async function getScenarios(projectId: string): Promise<WithStringId<z.infer<typeof Scenario>>[]> {
|
||||
|
|
@ -46,6 +46,7 @@ export async function createScenario(projectId: string, name: string, descriptio
|
|||
name,
|
||||
description,
|
||||
context: '',
|
||||
criteria: '',
|
||||
lastUpdatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
|
|
@ -56,7 +57,12 @@ export async function createScenario(projectId: string, name: string, descriptio
|
|||
export async function updateScenario(
|
||||
projectId: string,
|
||||
scenarioId: string,
|
||||
updates: { name?: string; description?: string; context?: string }
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
context?: string;
|
||||
criteria?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
|
|
@ -83,4 +89,159 @@ export async function deleteScenario(projectId: string, scenarioId: string): Pro
|
|||
_id: new ObjectId(scenarioId),
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRuns(projectId: string): Promise<WithStringId<z.infer<typeof SimulationRun>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const runs = await simulationRunsCollection
|
||||
.find({ projectId })
|
||||
.sort({ startedAt: -1 }) // Most recent first
|
||||
.toArray();
|
||||
|
||||
return runs.map(run => ({
|
||||
...run,
|
||||
_id: run._id.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof SimulationRun>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const run = await simulationRunsCollection.findOne({
|
||||
_id: new ObjectId(runId),
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Error('Run not found');
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
_id: run._id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRun(
|
||||
projectId: string,
|
||||
scenarioIds: string[]
|
||||
): Promise<WithStringId<z.infer<typeof SimulationRun>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const run = {
|
||||
projectId,
|
||||
status: 'pending' as const,
|
||||
scenarioIds,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await simulationRunsCollection.insertOne(run);
|
||||
|
||||
return {
|
||||
...run,
|
||||
_id: result.insertedId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRunStatus(
|
||||
projectId: string,
|
||||
runId: string,
|
||||
status: z.infer<typeof SimulationRun>['status'],
|
||||
completedAt?: string
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const updateData: Partial<z.infer<typeof SimulationRun>> = {
|
||||
status,
|
||||
};
|
||||
|
||||
if (completedAt) {
|
||||
updateData.completedAt = completedAt;
|
||||
}
|
||||
|
||||
await simulationRunsCollection.updateOne(
|
||||
{
|
||||
_id: new ObjectId(runId),
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
$set: updateData,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRunResults(
|
||||
projectId: string,
|
||||
runId: string
|
||||
): Promise<WithStringId<z.infer<typeof SimulationResult>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const results = await simulationResultsCollection
|
||||
.find({
|
||||
runId,
|
||||
projectId,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
return results.map(result => ({
|
||||
...result,
|
||||
_id: result._id.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createRunResult(
|
||||
projectId: string,
|
||||
runId: string,
|
||||
scenarioId: string,
|
||||
result: z.infer<typeof SimulationResult>['result'],
|
||||
details: string
|
||||
): Promise<string> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const resultDoc = {
|
||||
projectId,
|
||||
runId,
|
||||
scenarioId,
|
||||
result,
|
||||
details,
|
||||
};
|
||||
|
||||
const insertResult = await simulationResultsCollection.insertOne(resultDoc);
|
||||
return insertResult.insertedId.toString();
|
||||
}
|
||||
|
||||
export async function createAggregateResult(
|
||||
projectId: string,
|
||||
runId: string,
|
||||
total: number,
|
||||
pass: number,
|
||||
fail: number
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await simulationRunsCollection.updateOne(
|
||||
{ _id: new ObjectId(runId), projectId },
|
||||
{
|
||||
$set: {
|
||||
aggregateResults: { total, pass, fail }
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAggregateResult(
|
||||
projectId: string,
|
||||
runId: string
|
||||
): Promise<z.infer<typeof SimulationAggregateResult> | null> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const run = await simulationRunsCollection.findOne({
|
||||
_id: new ObjectId(runId),
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!run || !run.aggregateResults) return null;
|
||||
|
||||
return run.aggregateResults;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { Project } from "./types/project_types";
|
|||
import { EmbeddingDoc } from "./types/datasource_types";
|
||||
import { DataSourceDoc } from "./types/datasource_types";
|
||||
import { DataSource } from "./types/datasource_types";
|
||||
import { Scenario, SimulationResult, SimulationRun } from "./types/testing_types";
|
||||
import { Scenario, SimulationResult, SimulationRun, SimulationAggregateResult } from "./types/testing_types";
|
||||
import { z } from 'zod';
|
||||
|
||||
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017");
|
||||
|
|
|
|||
|
|
@ -1,42 +1,51 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const Scenario = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string().min(1, "Name cannot be empty"),
|
||||
description: z.string().min(1, "Description cannot be empty"),
|
||||
criteria: z.string().default(''),
|
||||
context: z.string().default(''),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
});export const SimulationArticleData = z.object({
|
||||
});
|
||||
|
||||
export const SimulationArticleData = z.object({
|
||||
articleUrl: z.string(),
|
||||
articleTitle: z.string().default('').optional(),
|
||||
articleContent: z.string().default('').optional(),
|
||||
});
|
||||
|
||||
export const SimulationScenarioData = z.object({
|
||||
scenario: z.string(),
|
||||
context: z.string().default(''),
|
||||
});
|
||||
|
||||
export const SimulationChatMessagesData = z.object({
|
||||
chatMessages: z.string(),
|
||||
});
|
||||
|
||||
export const SimulationData = z.union([SimulationArticleData, SimulationScenarioData, SimulationChatMessagesData]);
|
||||
|
||||
export const SimulationAggregateResult = z.object({
|
||||
total: z.number(),
|
||||
pass: z.number(),
|
||||
fail: z.number(),
|
||||
});
|
||||
|
||||
export const SimulationRun = z.object({
|
||||
projectId: z.string(),
|
||||
status: z.union([
|
||||
z.literal('pending'),
|
||||
z.literal('running'),
|
||||
z.literal('completed'),
|
||||
z.literal('cancelled'),
|
||||
z.literal('failed')
|
||||
]),
|
||||
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed']),
|
||||
scenarioIds: z.array(z.string()),
|
||||
startedAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string().optional(),
|
||||
aggregateResults: SimulationAggregateResult.optional(),
|
||||
});
|
||||
|
||||
export const SimulationResult = z.object({
|
||||
projectId: z.string(),
|
||||
runId: z.string(),
|
||||
scenarioId: z.string(),
|
||||
result: z.union([z.literal('pass'), z.literal('fail')]),
|
||||
details: z.string()
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -6,7 +6,6 @@ import { PlaygroundChat } from "../../../lib/types/types";
|
|||
import { Workflow } from "../../../lib/types/workflow_types";
|
||||
import { SimulationData } from "../../../lib/types/testing_types";
|
||||
import { SimulationScenarioData } from "../../../lib/types/testing_types";
|
||||
import { SimulateScenarioOption, SimulateURLOption } from "./simulation-options";
|
||||
import { Chat } from "./chat";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { ActionButton, Pane } from "../workflow/pane";
|
||||
|
|
@ -36,7 +35,6 @@ export function App({
|
|||
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
|
||||
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
|
||||
const [loadingChat, setLoadingChat] = useState<boolean>(false);
|
||||
const [viewSimulationMenu, setViewSimulationMenu] = useState<boolean>(false);
|
||||
const [counter, setCounter] = useState<number>(0);
|
||||
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
|
||||
projectId,
|
||||
|
|
@ -48,7 +46,7 @@ export function App({
|
|||
|
||||
const beginSimulation = useCallback((data: z.infer<typeof SimulationData>) => {
|
||||
setExistingChatId(null);
|
||||
setViewSimulationMenu(false);
|
||||
setLoadingChat(true);
|
||||
setCounter(counter + 1);
|
||||
setChat({
|
||||
projectId,
|
||||
|
|
@ -88,7 +86,7 @@ export function App({
|
|||
|
||||
function handleNewChatButtonClick() {
|
||||
setExistingChatId(null);
|
||||
setViewSimulationMenu(false);
|
||||
setLoadingChat(true);
|
||||
setCounter(counter + 1);
|
||||
setChat({
|
||||
projectId,
|
||||
|
|
@ -100,7 +98,7 @@ export function App({
|
|||
}
|
||||
|
||||
return (
|
||||
<Pane title={viewSimulationMenu ? <SimulateLabel /> : "Chat"} actions={[
|
||||
<Pane title="Chat" actions={[
|
||||
<ActionButton
|
||||
key="new-chat"
|
||||
icon={<MessageSquarePlusIcon size={16} />}
|
||||
|
|
@ -117,10 +115,10 @@ export function App({
|
|||
</ActionButton>,
|
||||
]}>
|
||||
<div className="h-full overflow-auto">
|
||||
{!viewSimulationMenu && loadingChat && <div className="flex justify-center items-center h-full">
|
||||
{loadingChat && <div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>}
|
||||
{!viewSimulationMenu && !loadingChat && <Chat
|
||||
{!loadingChat && <Chat
|
||||
key={existingChatId || 'chat-' + counter}
|
||||
chat={chat}
|
||||
initialChatId={existingChatId || null}
|
||||
|
|
@ -128,7 +126,6 @@ export function App({
|
|||
workflow={workflow}
|
||||
messageSubscriber={messageSubscriber}
|
||||
/>}
|
||||
{viewSimulationMenu && <SimulateScenarioOption beginSimulation={beginSimulation} projectId={projectId} />}
|
||||
</div>
|
||||
</Pane>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Spinner, Textarea } from "@nextui-org/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getScenarios, createScenario, updateScenario, deleteScenario } from "../../../actions/scenario_actions";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { Scenario } from "../../../lib/types/testing_types";
|
||||
import { z } from "zod";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { EllipsisVerticalIcon, PlayIcon, PlusIcon } from "lucide-react";
|
||||
|
||||
export function AddScenarioForm({
|
||||
onAdd,
|
||||
}: {
|
||||
onAdd: (name: string, description: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim() || !description.trim()) {
|
||||
setError("Name and description are required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await onAdd(name.trim(), description.trim());
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Invalid input");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="flex flex-col gap-2 border rounded-lg p-4 shadow-sm">
|
||||
<div className="font-semibold text-gray-500">Add scenario</div>
|
||||
<Input
|
||||
label="Scenario Name"
|
||||
labelPlacement="outside"
|
||||
value={name}
|
||||
placeholder="Provide a name for the scenario"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
isInvalid={!!error}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Scenario Description"
|
||||
labelPlacement="outside"
|
||||
value={description}
|
||||
placeholder="Describe the test scenario"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
isInvalid={!!error}
|
||||
required
|
||||
/>
|
||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
isLoading={saving}
|
||||
isDisabled={saving || !name.trim() || !description.trim()}
|
||||
size="sm"
|
||||
className="self-start"
|
||||
variant="bordered"
|
||||
startContent={
|
||||
<PlusIcon size={16} />
|
||||
}
|
||||
>
|
||||
Add scenario
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ScenarioList({
|
||||
projectId,
|
||||
onPlay,
|
||||
}: {
|
||||
projectId: string;
|
||||
onPlay: (scenario: z.infer<typeof Scenario>) => void;
|
||||
}) {
|
||||
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof Scenario> & {
|
||||
tmp?: boolean;
|
||||
}>[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tmpScenarioId, setTmpScenarioId] = useState<number>(0);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getScenarios(projectId)
|
||||
.then(setScenarios)
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
async function handleAddScenario(name: string, description: string) {
|
||||
try {
|
||||
const tmpId = 'tmp-' + tmpScenarioId;
|
||||
setTmpScenarioId(tmpScenarioId + 1);
|
||||
setSaving(true);
|
||||
setShowAddForm(false);
|
||||
setScenarios([...scenarios, {
|
||||
_id: tmpId,
|
||||
name,
|
||||
description,
|
||||
context: '',
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
tmp: true,
|
||||
}]);
|
||||
const id = await createScenario(projectId, name, description);
|
||||
setScenarios([...scenarios, {
|
||||
_id: id,
|
||||
name,
|
||||
description,
|
||||
context: '',
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
tmp: false,
|
||||
}]);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Invalid input");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleEditScenario(scenarioId: string, name: string, description: string) {
|
||||
setSaving(true);
|
||||
setScenarios(scenarios.map(scenario =>
|
||||
scenario._id === scenarioId
|
||||
? { ...scenario, name, description, context: scenario.context }
|
||||
: scenario
|
||||
));
|
||||
await updateScenario(projectId, scenarioId, name, description);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleDeleteScenario(scenarioId: string) {
|
||||
setSaving(true);
|
||||
setScenarios(scenarios.filter(scenario => scenario._id !== scenarioId));
|
||||
await deleteScenario(projectId, scenarioId);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between gap-2 items-center">
|
||||
<div className="font-semibold text-gray-500">Scenarios</div>
|
||||
{saving && <div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<div className="text-sm text-gray-500">Saving...</div>
|
||||
</div>}
|
||||
{!showAddForm && <Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
>
|
||||
Add scenario
|
||||
</Button>}
|
||||
</div>
|
||||
{loading && <div className="flex justify-center items-center p-8 gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div className="text-sm text-gray-500">Loading scenarios...</div>
|
||||
</div>}
|
||||
|
||||
{showAddForm && <AddScenarioForm onAdd={handleAddScenario} />}
|
||||
|
||||
{!loading && scenarios.length === 0 && <div className="flex justify-center items-center p-8 gap-2">
|
||||
<div className="text-sm text-gray-500">No scenarios added</div>
|
||||
</div>}
|
||||
|
||||
{scenarios.length > 0 && <div className="flex flex-col gap-2">
|
||||
{scenarios.map((scenario) => (
|
||||
<div key={scenario._id} className="flex flex-col gap-1 rounded-md shadow-sm border border-gray-300">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="grow font-semibold text-lg">
|
||||
<EditableField
|
||||
key={'name'}
|
||||
placeholder="Scenario Name"
|
||||
value={scenario.name}
|
||||
onChange={(value) => handleEditScenario(scenario._id, value, scenario.description)}
|
||||
locked={scenario.tmp}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center mr-2 bg-gray-100 p-1 rounded-b-md">
|
||||
<button
|
||||
className="p-1 flex items-center gap-1 text-gray-500 hover:text-blue-500"
|
||||
onClick={() => onPlay(scenario)}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
<div className="text-sm font-semibold">Run</div>
|
||||
</button>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button className="p-1 flex items-center gap-1 text-gray-500 hover:text-gray-700">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={scenario.tmp ? ['delete'] : ['']}
|
||||
onAction={(key) => {
|
||||
if (key === 'delete') {
|
||||
handleDeleteScenario(scenario._id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
color="danger"
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<EditableField
|
||||
key={'description'}
|
||||
multiline
|
||||
markdown
|
||||
light
|
||||
placeholder="Scenario Description"
|
||||
value={scenario.description}
|
||||
onChange={(value) => handleEditScenario(scenario._id, scenario.name, value)}
|
||||
locked={scenario.tmp}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
'use client';
|
||||
import { Input, Textarea } from "@nextui-org/react";
|
||||
import { FormStatusButton } from "../../../lib/components/FormStatusButton";
|
||||
import { SimulationData } from "../../../lib/types/testing_types";
|
||||
import { z } from "zod";
|
||||
import { scrapeWebpage } from "../../../actions/actions";
|
||||
import { ScenarioList } from "./scenario-list";
|
||||
|
||||
export function SimulateURLOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
function handleUrlSimulationSubmit(formData: FormData) {
|
||||
const url = formData.get('url') as string;
|
||||
// fetch article content and title
|
||||
scrapeWebpage(url).then((result) => {
|
||||
beginSimulation({
|
||||
articleUrl: url,
|
||||
articleContent: result.content,
|
||||
articleTitle: result.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
return <form action={handleUrlSimulationSubmit} className="flex flex-col gap-2">
|
||||
<div>Use a URL / article link:</div>
|
||||
<input type="hidden" name="projectId" value={projectId} />
|
||||
<Input
|
||||
variant="bordered"
|
||||
placeholder="https://acme.com/articles/product-detiails"
|
||||
name="url"
|
||||
required
|
||||
endContent={<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
|
||||
</svg>,
|
||||
children: "Go"
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
||||
export function SimulateScenarioOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScenarioList
|
||||
projectId={projectId}
|
||||
onPlay={(scenario) => beginSimulation({
|
||||
scenario: scenario.description,
|
||||
context: scenario.context,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulateChatContextOption({
|
||||
projectId,
|
||||
beginSimulation,
|
||||
}: {
|
||||
projectId: string;
|
||||
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
|
||||
}) {
|
||||
function handleChatContextSimulationSubmit(formData: FormData) {
|
||||
beginSimulation({
|
||||
chatMessages: formData.get('context') as string,
|
||||
});
|
||||
}
|
||||
return <form action={handleChatContextSimulationSubmit} className="flex flex-col gap-2">
|
||||
<div>Use a previous chat context:</div>
|
||||
<input type="hidden" name="projectId" value={projectId} />
|
||||
<Textarea
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
required
|
||||
name="context"
|
||||
placeholder={JSON.stringify([
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hello! How can I help you today?"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello! I need help with..."
|
||||
}
|
||||
], null, 2)}
|
||||
endContent={<FormStatusButton
|
||||
props={{
|
||||
type: "submit",
|
||||
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
|
||||
</svg>,
|
||||
children: "Go"
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
|
@ -1,49 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PlusIcon, PencilIcon, XMarkIcon, DocumentDuplicateIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon } from '@heroicons/react/24/outline';
|
||||
import { PlusIcon, PencilIcon, XMarkIcon, DocumentDuplicateIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
getScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
getRuns,
|
||||
getRun,
|
||||
getRunResults,
|
||||
createRun,
|
||||
createRunResult,
|
||||
updateRunStatus,
|
||||
createAggregateResult,
|
||||
getAggregateResult,
|
||||
} from '../../../actions/simulation_actions';
|
||||
import { type WithStringId } from '../../../lib/types/types';
|
||||
import { Scenario } from "../../../lib/types/testing_types";
|
||||
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
|
||||
import { z } from 'zod';
|
||||
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
|
||||
import { ScenarioViewer } from './components/ScenarioComponents';
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
|
||||
type SimulationResult = {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
passed: boolean;
|
||||
details: string;
|
||||
scenario: ScenarioType;
|
||||
};
|
||||
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
|
||||
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
|
||||
|
||||
type SimulationReport = {
|
||||
totalScenarios: number;
|
||||
passedScenarios: number;
|
||||
failedScenarios: number;
|
||||
results: SimulationResult[];
|
||||
results: z.infer<typeof SimulationResult>[];
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
const dummySimulator = async (scenario: ScenarioType): Promise<SimulationResult> => {
|
||||
const dummySimulator = async (scenario: ScenarioType, runId: string, projectId: string): Promise<z.infer<typeof SimulationResult>> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const passed = Math.random() > 0.5;
|
||||
|
||||
return {
|
||||
const result: z.infer<typeof SimulationResult> = {
|
||||
projectId: projectId,
|
||||
runId: runId,
|
||||
scenarioId: scenario._id,
|
||||
scenarioName: scenario.name,
|
||||
passed,
|
||||
result: passed ? 'pass' : 'fail' as const,
|
||||
details: passed
|
||||
? "The bot successfully completed the conversation"
|
||||
: "The bot could not handle the conversation",
|
||||
scenario: scenario,
|
||||
};
|
||||
|
||||
await createRunResult(
|
||||
projectId,
|
||||
runId,
|
||||
scenario._id,
|
||||
result.result,
|
||||
result.details
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function SimulationApp() {
|
||||
|
|
@ -56,6 +70,11 @@ export default function SimulationApp() {
|
|||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [simulationReport, setSimulationReport] = useState<SimulationReport | null>(null);
|
||||
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
|
||||
const [runs, setRuns] = useState<SimulationRunType[]>([]);
|
||||
const [activeRun, setActiveRun] = useState<SimulationRunType | null>(null);
|
||||
const [runResults, setRunResults] = useState<SimulationResultType[]>([]);
|
||||
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
|
||||
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
|
||||
|
||||
// Load scenarios on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -71,6 +90,61 @@ export default function SimulationApp() {
|
|||
}
|
||||
}, [menuOpenScenarioId]);
|
||||
|
||||
// Modify the fetchRuns function to also fetch results
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setIsLoadingRuns(true);
|
||||
try {
|
||||
const runsData = await getRuns(projectId as string);
|
||||
setRuns(runsData);
|
||||
|
||||
// Fetch results for all runs
|
||||
const resultsPromises = runsData.map(run =>
|
||||
getRunResults(projectId as string, run._id)
|
||||
);
|
||||
const allResults = await Promise.all(resultsPromises);
|
||||
|
||||
// Create a map of run ID to results
|
||||
const resultsMap = runsData.reduce((acc, run, index) => ({
|
||||
...acc,
|
||||
[run._id]: allResults[index]
|
||||
}), {});
|
||||
|
||||
setAllRunResults(resultsMap);
|
||||
} catch (error) {
|
||||
console.error('Error fetching runs:', error);
|
||||
} finally {
|
||||
setIsLoadingRuns(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Update the useEffect hooks to include fetchRuns
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
fetchRuns();
|
||||
}, [projectId, fetchRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !activeRun || activeRun.status === 'completed' || activeRun.status === 'cancelled') return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updatedRun = await getRun(projectId as string, activeRun._id);
|
||||
setActiveRun(updatedRun);
|
||||
|
||||
if (updatedRun.status === 'completed') {
|
||||
const results = await getRunResults(projectId as string, activeRun._id);
|
||||
setRunResults(results);
|
||||
fetchRuns(); // Refresh the runs list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling run status:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeRun, projectId, fetchRuns]);
|
||||
|
||||
const createNewScenario = async () => {
|
||||
if (!projectId) return;
|
||||
const newScenarioId = await createScenario(
|
||||
|
|
@ -96,6 +170,7 @@ export default function SimulationApp() {
|
|||
{
|
||||
name: updatedScenario.name,
|
||||
description: updatedScenario.description,
|
||||
criteria: updatedScenario.criteria,
|
||||
context: updatedScenario.context,
|
||||
}
|
||||
);
|
||||
|
|
@ -127,31 +202,61 @@ export default function SimulationApp() {
|
|||
};
|
||||
|
||||
const runAllScenarios = async () => {
|
||||
if (!projectId) return;
|
||||
setIsRunning(true);
|
||||
setSimulationReport(null);
|
||||
|
||||
try {
|
||||
const results: SimulationResult[] = [];
|
||||
const newRun = await createRun(
|
||||
projectId as string,
|
||||
scenarios.map(s => s._id)
|
||||
);
|
||||
setActiveRun(newRun);
|
||||
|
||||
const shouldMock = process.env.NEXT_PUBLIC_MOCK_SIMULATION_RESULTS === 'true';
|
||||
|
||||
// Run each scenario through the simulator
|
||||
for (const scenario of scenarios) {
|
||||
const result = await dummySimulator(scenario);
|
||||
results.push(result);
|
||||
if (shouldMock) {
|
||||
console.log('Using mock simulation...');
|
||||
|
||||
await updateRunStatus(projectId as string, newRun._id, 'running');
|
||||
|
||||
// Run all scenarios and collect results
|
||||
const mockResults = await Promise.all(
|
||||
scenarios.map(scenario =>
|
||||
dummySimulator(scenario, newRun._id, projectId as string)
|
||||
)
|
||||
);
|
||||
|
||||
// Calculate and store aggregate results before marking as complete
|
||||
const total = scenarios.length;
|
||||
const pass = mockResults.filter(r => r.result === 'pass').length;
|
||||
const fail = mockResults.filter(r => r.result === 'fail').length;
|
||||
|
||||
await createAggregateResult(
|
||||
projectId as string,
|
||||
newRun._id,
|
||||
total,
|
||||
pass,
|
||||
fail
|
||||
);
|
||||
|
||||
await updateRunStatus(
|
||||
projectId as string,
|
||||
newRun._id,
|
||||
'completed',
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
const results = await getRunResults(projectId as string, newRun._id);
|
||||
setRunResults(results);
|
||||
|
||||
const updatedRun = await getRun(projectId as string, newRun._id);
|
||||
setActiveRun(updatedRun);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
await fetchRuns();
|
||||
} catch (error) {
|
||||
console.error('Error running scenarios:', error);
|
||||
console.error('Error starting scenarios:', error);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
|
|
@ -243,154 +348,43 @@ export default function SimulationApp() {
|
|||
{/* 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}
|
||||
/>
|
||||
)
|
||||
<ScenarioViewer
|
||||
scenario={selectedScenario}
|
||||
onSave={handleUpdateScenario}
|
||||
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="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Simulation Runs</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="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>
|
||||
</>
|
||||
{isLoadingRuns ? (
|
||||
<div className="text-center py-4">Loading runs...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">No simulation runs yet</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 className="space-y-4">
|
||||
{runs.map((run) => (
|
||||
<SimulationResultCard
|
||||
key={run._id}
|
||||
run={run}
|
||||
results={allRunResults[run._id] || []}
|
||||
scenarios={scenarios}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-gray-500 mt-10">
|
||||
Select a scenario or run all scenarios
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -398,140 +392,3 @@ export default function SimulationApp() {
|
|||
</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 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">CONTEXT</div>
|
||||
<div className="text-base whitespace-pre-wrap">{scenario.context}</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 [context, setContext] = useState(scenario.context || '');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...scenario,
|
||||
name,
|
||||
description,
|
||||
context,
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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">CONTEXT</div>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { WithStringId } from '../../../../lib/types/types';
|
||||
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types";
|
||||
import { z } from 'zod';
|
||||
import { getAggregateResult } from '../../../../actions/simulation_actions';
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
|
||||
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
|
||||
|
||||
interface SimulationResultCardProps {
|
||||
run: SimulationRunType;
|
||||
results: SimulationResultType[];
|
||||
scenarios: ScenarioType[];
|
||||
}
|
||||
|
||||
export const SimulationResultCard = ({ run, results, scenarios }: SimulationResultCardProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [expandedScenarios, setExpandedScenarios] = useState<Set<string>>(new Set());
|
||||
|
||||
// Replace the manual calculations with aggregate results from the run object
|
||||
const totalScenarios = run.aggregateResults?.total ?? run.scenarioIds.length;
|
||||
const passedScenarios = run.aggregateResults?.pass ?? 0;
|
||||
const failedScenarios = run.aggregateResults?.fail ?? 0;
|
||||
|
||||
const statusLabelClass = "px-3 py-1 rounded text-xs min-w-[60px] text-center uppercase font-semibold";
|
||||
|
||||
const formatMainTitle = (date: string) => {
|
||||
return `Run from ${new Date(date).toLocaleString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
})}`;
|
||||
};
|
||||
|
||||
const formatDateTime = (date: string) => {
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const getDuration = () => {
|
||||
if (!run.completedAt) return 'In Progress';
|
||||
const start = new Date(run.startedAt);
|
||||
const end = new Date(run.completedAt);
|
||||
const diff = end.getTime() - start.getTime();
|
||||
return `${(diff / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const toggleScenario = (scenarioId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering parent's onClick
|
||||
setExpandedScenarios(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(scenarioId)) {
|
||||
newSet.delete(scenarioId);
|
||||
} else {
|
||||
newSet.add(scenarioId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg mb-4 shadow-sm">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
<div className="text-lg font-semibold">
|
||||
{formatMainTitle(run.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`${statusLabelClass} ${
|
||||
run.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
run.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-4 border-t">
|
||||
{/* Simplified timing information */}
|
||||
<div className="mb-6 text-sm text-gray-500 space-y-1">
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 text-gray-600">Completed:</span>
|
||||
<span>{run.completedAt ? formatDateTime(run.completedAt) : 'Not completed'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 text-gray-600">Duration:</span>
|
||||
<span>{getDuration()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results statistics */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-gray-50">
|
||||
<div className="text-sm text-gray-600">Total Scenarios</div>
|
||||
<div className="text-2xl font-semibold">{totalScenarios}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-50">
|
||||
<div className="text-sm text-green-600">Passed</div>
|
||||
<div className="text-2xl font-semibold text-green-700">{passedScenarios}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-red-50">
|
||||
<div className="text-sm text-red-600">Failed</div>
|
||||
<div className="text-2xl font-semibold text-red-700">{failedScenarios}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{run.scenarioIds.map(scenarioId => {
|
||||
const scenario = scenarios.find(s => s._id === scenarioId);
|
||||
const result = results.find(r => r.scenarioId === scenarioId);
|
||||
const isScenarioExpanded = expandedScenarios.has(scenarioId);
|
||||
|
||||
return scenario && (
|
||||
<div
|
||||
key={scenarioId}
|
||||
className={`border rounded-lg overflow-hidden ${
|
||||
result?.result === 'pass' ? 'bg-green-50 border-green-200' :
|
||||
result?.result === 'fail' ? 'bg-red-50 border-red-200' :
|
||||
'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="p-3 flex items-center justify-between cursor-pointer hover:bg-opacity-80"
|
||||
onClick={(e) => toggleScenario(scenarioId, e)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isScenarioExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-600" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{scenario.name}</span>
|
||||
</div>
|
||||
{result && (
|
||||
<span className={`${statusLabelClass} ${
|
||||
result.result === 'pass' ? 'bg-green-200 text-green-900' : 'bg-red-200 text-red-900'
|
||||
}`}>
|
||||
{result.result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isScenarioExpanded && (
|
||||
<div className="p-3 border-t border-opacity-50 space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Description</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{scenario.description}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Criteria</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{scenario.criteria || 'No criteria specified'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Context</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{scenario.context || 'No context provided'}
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Result Details</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{result.details}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScenarioResultCardProps {
|
||||
scenario: ScenarioType;
|
||||
result?: SimulationResultType;
|
||||
}
|
||||
|
||||
export const ScenarioResultCard = ({ scenario, result }: ScenarioResultCardProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg mb-2 last:mb-0">
|
||||
<div
|
||||
className="p-3 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<span className="font-medium">{scenario.name}</span>
|
||||
</div>
|
||||
{result && (
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
result.result === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{result.result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t space-y-2 bg-gray-50">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">Description</div>
|
||||
<div className="text-sm">{scenario.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">Criteria</div>
|
||||
<div className="text-sm">{scenario.criteria}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">Context</div>
|
||||
<div className="text-sm">{scenario.context}</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">Result Details</div>
|
||||
<div className="text-sm">{result.details}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { WithStringId } from '../../../../lib/types/types';
|
||||
import { Scenario } from "../../../../lib/types/testing_types";
|
||||
import { z } from 'zod';
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
|
||||
interface ScenarioViewerProps {
|
||||
scenario: ScenarioType;
|
||||
onSave: (scenario: ScenarioType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProps) {
|
||||
const [editedScenario, setEditedScenario] = useState<ScenarioType>(scenario);
|
||||
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Reset state when scenario changes
|
||||
useEffect(() => {
|
||||
setEditedScenario(scenario);
|
||||
}, [scenario]);
|
||||
|
||||
const handleChange = useCallback((field: keyof ScenarioType, value: string) => {
|
||||
setEditedScenario(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// Clear existing timeout
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
onSave({
|
||||
...editedScenario,
|
||||
[field]: value,
|
||||
});
|
||||
}, 500);
|
||||
|
||||
setSaveTimeout(timeoutId);
|
||||
}, [editedScenario, onSave, saveTimeout]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
};
|
||||
}, [saveTimeout]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Scenario Details</h1>
|
||||
<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 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>
|
||||
<input
|
||||
type="text"
|
||||
value={editedScenario.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</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>
|
||||
<textarea
|
||||
value={editedScenario.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
|
||||
style={{ height: 'auto', minHeight: '24px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
</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">CRITERIA</div>
|
||||
<textarea
|
||||
value={editedScenario.criteria}
|
||||
onChange={(e) => handleChange('criteria', e.target.value)}
|
||||
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
|
||||
style={{ height: 'auto', minHeight: '24px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
</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">CONTEXT</div>
|
||||
<textarea
|
||||
value={editedScenario.context}
|
||||
onChange={(e) => handleChange('context', e.target.value)}
|
||||
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
|
||||
style={{ height: 'auto', minHeight: '24px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/rowboat/package-lock.json
generated
2
apps/rowboat/package-lock.json
generated
|
|
@ -13,7 +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",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@langchain/core": "^0.3.7",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@mendable/firecrawl-js": "^1.0.3",
|
||||
|
|
|
|||
|
|
@ -18,7 +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",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@langchain/core": "^0.3.7",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@mendable/firecrawl-js": "^1.0.3",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue