Add test profiles and overhaul testing

This commit is contained in:
ramnique 2025-02-27 23:42:04 +05:30
parent 8c1b5346f3
commit 768c5749a0
30 changed files with 3473 additions and 1644 deletions

View file

@ -6,7 +6,6 @@ import { EmbeddingRecord } from "../lib/types/datasource_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { GetInformationToolResult } from "../lib/types/tool_types";
import { EmbeddingDoc } from "../lib/types/datasource_types";
import { SimulationData } from "../lib/types/testing_types";
import { generateObject, generateText, embed } from "ai";
import { dataSourceDocsCollection, dataSourcesCollection, embeddingsCollection, webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
@ -21,6 +20,7 @@ import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { qdrantClient } from "../lib/qdrant";
import { ObjectId } from "mongodb";
import { TestProfile } from "../lib/types/testing_types";
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
@ -99,13 +99,13 @@ export async function getAssistantResponse(
};
}
export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer<typeof apiV1.ChatMessage>[]): Promise<string> {
export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer<typeof apiV1.ChatMessage>[], testProfile: z.infer<typeof TestProfile>): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
return await mockToolResponse(toolId, messages);
return await mockToolResponse(toolId, messages, testProfile);
}
export async function getInformationTool(
@ -123,39 +123,13 @@ export async function getInformationTool(
export async function simulateUserResponse(
projectId: string,
messages: z.infer<typeof apiV1.ChatMessage>[],
simulationData: z.infer<typeof SimulationData>
scenario: string,
): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
const articlePrompt = `
# Your Specific Task:
## Context:
Here is a help article:
Content:
<START_ARTICLE_CONTENT>
Title: {{title}}
{{content}}
<END_ARTICLE_CONTENT>
## Task definition:
Pretend to be a user reaching out to customer support. Chat with the
customer support assistant, assuming your issue or query is from this article.
Ask follow-up questions and make it real-world like. Don't do dummy
conversations. Your conversation should be a maximum of 5 user turns.
As output, simply provide your (user) turn of conversation.
After you are done with the chat, keep replying with a single word EXIT
in all capitals.
`;
const scenarioPrompt = `
# Your Specific Task:
@ -181,30 +155,6 @@ After you are done with the chat, keep replying with a single word EXIT
in all capitals.
`;
const previousChatPrompt = `
# Your Specific Task:
## Context:
Here is a chat between a user and a customer support assistant:
Chat:
<PREVIOUS_CHAT>
{{messages}}
<END_PREVIOUS_CHAT>
## Task definition:
Pretend to be a user reaching out to customer support. Chat with the
customer support assistant, assuming your issue based on this previous chat.
Ask follow-up questions and make it real-world like. Don't do dummy
conversations. Your conversation should be a maximum of 5 user turns.
As output, simply provide your (user) turn of conversation.
After you are done with the chat, keep replying with a single word EXIT
in all capitals.
`;
await projectAuthCheck(projectId);
// flip message assistant / user message
@ -219,19 +169,9 @@ in all capitals.
// simulate user call
let prompt;
if ('articleUrl' in simulationData) {
prompt = articlePrompt
.replace('{{title}}', simulationData.articleTitle || '')
.replace('{{content}}', simulationData.articleContent || '');
}
if ('scenario' in simulationData) {
prompt = scenarioPrompt
.replace('{{scenario}}', simulationData.scenario);
}
if ('chatMessages' in simulationData) {
prompt = previousChatPrompt
.replace('{{messages}}', simulationData.chatMessages);
}
prompt = scenarioPrompt
.replace('{{scenario}}', scenario);
const { text } = await generateText({
model: openai("gpt-4o"),
system: prompt || '',

View file

@ -1,7 +1,7 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId } from "mongodb";
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "../lib/mongodb";
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, testScenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection, testProfilesCollection } from "../lib/mongodb";
import { z } from 'zod';
import crypto from 'crypto';
import { revalidatePath } from "next/cache";
@ -41,6 +41,7 @@ export async function createProject(formData: FormData) {
const projectId = crypto.randomUUID();
const chatClientId = crypto.randomBytes(16).toString('base64url');
const secret = crypto.randomBytes(32).toString('hex');
const defaultTestProfileId = new ObjectId();
// create project
await projectsCollection.insertOne({
@ -52,12 +53,13 @@ export async function createProject(formData: FormData) {
chatClientId,
secret,
nextWorkflowNumber: 1,
testRunCounter: 0,
defaultTestProfileId: defaultTestProfileId.toString(),
});
// add first workflow version
const { agents, prompts, tools, startAgent } = templates[templateKey];
await agentWorkflowsCollection.insertOne({
_id: new ObjectId(),
projectId,
agents,
prompts,
@ -68,6 +70,17 @@ export async function createProject(formData: FormData) {
name: `Version 1`,
});
// add default test profile
await testProfilesCollection.insertOne({
_id: defaultTestProfileId,
projectId,
name: "Default",
context: "",
mockTools: false,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
});
// add user to project
await projectMembersCollection.insertOne({
userId: user.sub,
@ -198,7 +211,7 @@ export async function deleteProject(projectId: string) {
});
// delete scenarios
await scenariosCollection.deleteMany({
await testScenariosCollection.deleteMany({
projectId,
});

View file

@ -1,270 +0,0 @@
'use server';
import { ObjectId } from "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, 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>>[]> {
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 SimulationScenarioData>>> {
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<string> {
await projectAuthCheck(projectId);
const now = new Date().toISOString();
const result = await scenariosCollection.insertOne({
projectId,
name,
description,
context: '',
criteria: '',
lastUpdatedAt: now,
createdAt: now,
});
return result.insertedId.toString();
}
export async function updateScenario(
projectId: string,
scenarioId: string,
updates: {
name?: string;
description?: string;
context?: string;
criteria?: 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,
});
}
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[],
workflowId: string
): Promise<WithStringId<z.infer<typeof SimulationRun>>> {
await projectAuthCheck(projectId);
const run = {
projectId,
status: 'pending' as const,
scenarioIds,
workflowId,
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;
}
export async function deleteRun(projectId: string, runId: string) {
try {
// Delete the run using the collection directly
await simulationRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId: projectId
});
// Delete associated results using the collection directly
await simulationResultsCollection.deleteMany({
runId: runId,
projectId: projectId
});
return { success: true };
} catch (error) {
console.error('Error deleting run:', error);
throw new Error('Failed to delete run');
}
}

View file

@ -0,0 +1,575 @@
'use server';
import { ObjectId } from "mongodb";
import { testScenariosCollection, testSimulationsCollection, testProfilesCollection, testRunsCollection, testResultsCollection, projectsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { projectAuthCheck } from "./project_actions";
import { type WithStringId } from "../lib/types/types";
import { TestScenario, TestSimulation, TestProfile, TestRun, TestResult } from "../lib/types/testing_types";
export async function listScenarios(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
scenarios: WithStringId<z.infer<typeof TestScenario>>[];
total: number;
}> {
await projectAuthCheck(projectId);
// Calculate skip value for pagination
const skip = (page - 1) * pageSize;
// Get total count for pagination
const total = await testScenariosCollection.countDocuments({ projectId });
// Get paginated scenarios
const scenarios = await testScenariosCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
scenarios: scenarios.map(scenario => ({
...scenario,
_id: scenario._id.toString(),
})),
total,
};
}
export async function getScenario(projectId: string, scenarioId: string): Promise<WithStringId<z.infer<typeof TestScenario>> | null> {
await projectAuthCheck(projectId);
// fetch scenario
const scenario = await testScenariosCollection.findOne({
_id: new ObjectId(scenarioId),
projectId,
});
if (!scenario) {
return null;
}
const { _id, ...rest } = scenario;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteScenario(projectId: string, scenarioId: string): Promise<void> {
await projectAuthCheck(projectId);
await testScenariosCollection.deleteOne({
_id: new ObjectId(scenarioId),
projectId,
});
}
export async function createScenario(
projectId: string,
data: {
name: string;
description: string;
}
): Promise<WithStringId<z.infer<typeof TestScenario>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testScenariosCollection.insertOne(doc);
return {
...doc,
_id: 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 testScenariosCollection.updateOne(
{
_id: new ObjectId(scenarioId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listSimulations(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
simulations: WithStringId<z.infer<typeof TestSimulation>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testSimulationsCollection.countDocuments({ projectId });
const simulations = await testSimulationsCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
simulations: simulations.map(simulation => ({
...simulation,
_id: simulation._id.toString(),
})),
total,
};
}
export async function getSimulation(projectId: string, simulationId: string): Promise<WithStringId<z.infer<typeof TestSimulation>> | null> {
await projectAuthCheck(projectId);
const simulation = await testSimulationsCollection.findOne({
_id: new ObjectId(simulationId),
projectId,
});
if (!simulation) {
return null;
}
const { _id, ...rest } = simulation;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteSimulation(projectId: string, simulationId: string): Promise<void> {
await projectAuthCheck(projectId);
await testSimulationsCollection.deleteOne({
_id: new ObjectId(simulationId),
projectId,
});
}
export async function createSimulation(
projectId: string,
data: {
name: string;
scenarioId: string;
profileId: string;
passCriteria: string;
}
): Promise<WithStringId<z.infer<typeof TestSimulation>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testSimulationsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateSimulation(
projectId: string,
simulationId: string,
updates: {
name?: string;
scenarioId?: string;
profileId?: string;
passCriteria?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testSimulationsCollection.updateOne(
{
_id: new ObjectId(simulationId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listProfiles(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
profiles: WithStringId<z.infer<typeof TestProfile>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testProfilesCollection.countDocuments({ projectId });
const profiles = await testProfilesCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
profiles: profiles.map(profile => ({
...profile,
_id: profile._id.toString(),
})),
total,
};
}
export async function getDefaultProfile(projectId: string): Promise<WithStringId<z.infer<typeof TestProfile>> | null> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
return null;
}
if (!project.defaultTestProfileId) {
// create a default profile
const profile = await createProfile(projectId, {
name: 'Default',
context: '',
mockTools: false,
mockPrompt: '',
});
await setDefaultProfile(projectId, profile._id);
return profile;
}
return getProfile(projectId, project.defaultTestProfileId);
}
export async function setDefaultProfile(projectId: string, profileId: string): Promise<void> {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { defaultTestProfileId: profileId } }
);
}
export async function getProfile(projectId: string, profileId: string): Promise<WithStringId<z.infer<typeof TestProfile>> | null> {
await projectAuthCheck(projectId);
const profile = await testProfilesCollection.findOne({
_id: new ObjectId(profileId),
projectId,
});
if (!profile) {
return null;
}
const { _id, ...rest } = profile;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteProfile(projectId: string, profileId: string): Promise<void> {
await projectAuthCheck(projectId);
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
default: false,
});
}
export async function createProfile(
projectId: string,
data: {
name: string;
context: string;
mockTools: boolean;
mockPrompt?: string;
}
): Promise<WithStringId<z.infer<typeof TestProfile>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testProfilesCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateProfile(
projectId: string,
profileId: string,
updates: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testProfilesCollection.updateOne(
{
_id: new ObjectId(profileId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listRuns(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
runs: WithStringId<z.infer<typeof TestRun>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testRunsCollection.countDocuments({ projectId });
const runs = await testRunsCollection
.find({ projectId })
.sort({ startedAt: -1 }) // Sort by most recent first
.skip(skip)
.limit(pageSize)
.toArray();
return {
runs: runs.map(run => ({
...run,
_id: run._id.toString(),
})),
total,
};
}
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof TestRun>> | null> {
await projectAuthCheck(projectId);
const run = await testRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run) {
return null;
}
const { _id, ...rest } = run;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId,
});
}
export async function createRun(
projectId: string,
data: {
simulationIds: string[];
workflowId: string;
}
): Promise<WithStringId<z.infer<typeof TestRun>>> {
await projectAuthCheck(projectId);
// Increment the testRunCounter and get the new value
const result = await projectsCollection.findOneAndUpdate(
{ _id: projectId },
{ $inc: { testRunCounter: 1 } },
{ returnDocument: 'after' }
);
if (!result) {
throw new Error("Project not found");
}
const runNumber = result.testRunCounter || 1;
const doc = {
...data,
projectId,
name: `Run #${runNumber}`,
status: 'pending' as const,
startedAt: new Date().toISOString(),
aggregateResults: {
total: 0,
pass: 0,
fail: 0,
},
};
const insertResult = await testRunsCollection.insertOne(doc);
return {
...doc,
_id: insertResult.insertedId.toString(),
};
}
export async function updateRun(
projectId: string,
runId: string,
updates: {
status?: 'pending' | 'running' | 'completed' | 'cancelled' | 'failed' | 'error';
completedAt?: string;
aggregateResults?: {
total: number;
pass: number;
fail: number;
};
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
};
await testRunsCollection.updateOne(
{
_id: new ObjectId(runId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listResults(
projectId: string,
runId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
results: WithStringId<z.infer<typeof TestResult>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testResultsCollection.countDocuments({ projectId, runId });
const results = await testResultsCollection
.find({ projectId, runId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
results: results.map(result => ({
...result,
_id: result._id.toString(),
})),
total,
};
}
export async function getResult(projectId: string, resultId: string): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
_id: new ObjectId(resultId),
projectId,
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteResult(projectId: string, resultId: string): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.deleteOne({
_id: new ObjectId(resultId),
projectId,
});
}
export async function createResult(
projectId: string,
data: {
runId: string;
simulationId: string;
result: 'pass' | 'fail';
details: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
};
const result = await testResultsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateResult(
projectId: string,
resultId: string,
updates: {
result?: 'pass' | 'fail';
details?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.updateOne(
{
_id: new ObjectId(resultId),
projectId,
},
{
$set: updates,
}
);
}

View file

@ -1,5 +1,5 @@
import { NextRequest } from "next/server";
import { agentWorkflowsCollection, db, projectsCollection } from "../../../../lib/mongodb";
import { agentWorkflowsCollection, db, projectsCollection, testProfilesCollection } from "../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
@ -9,6 +9,7 @@ import { getAgenticApiResponse, callClientToolWebhook, runRAGToolCall, mockToolR
import { check_query_limit } from "../../../../lib/rate_limiting";
import { apiV1 } from "rowboat-shared";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
// get next turn / agent response
export async function POST(
@ -69,8 +70,42 @@ export async function POST(
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
const MAX_TURNS = result.data.maxTurns ?? 3;
// if test profile is provided in the request, use it
let profile: z.infer<typeof TestProfile> = {
projectId: projectId,
name: 'Default',
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
context: '',
mockTools: false,
mockPrompt: '',
};
if (result.data.testProfileId) {
const testProfile = await testProfilesCollection.findOne({
projectId: projectId,
_id: new ObjectId(result.data.testProfileId),
});
if (!testProfile) {
logger.log(`Test profile ${result.data.testProfileId} not found for project ${projectId}`);
return Response.json({ error: "Test profile not found" }, { status: 404 });
}
profile = testProfile;
}
// if profile has a context available, overwrite the system message in the request (if there is one)
let currentMessages = reqMessages;
if (profile.context) {
// if there is a system message, overwrite it
const systemMessageIndex = reqMessages.findIndex(m => m.role === "system");
if (systemMessageIndex !== -1) {
currentMessages[systemMessageIndex].content = profile.context;
} else {
// if there is no system message, add one
currentMessages.unshift({ role: "system", content: profile.context });
}
}
const MAX_TURNS = result.data.maxTurns ?? 3;
let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name };
let turns = 0;
let hasToolCalls = false;
@ -140,9 +175,9 @@ export async function POST(
try {
// if tool is supposed to be mocked, mock it
const workflowTool = workflow.tools.find(t => t.name === toolCall.function.name);
if (workflowTool?.mockInPlayground) {
if (profile.mockTools) {
logger.log(`Mocking tool call ${toolCall.function.name}`);
result = await mockToolResponse(toolCall.id, currentMessages);
result = await mockToolResponse(toolCall.id, currentMessages, profile);
} else {
// else run the tool call by calling the client tool webhook
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);

View file

@ -0,0 +1,100 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listProfiles } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { z } from "zod";
interface ProfileSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>>) => void;
}
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: ProfileSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const fetchProfiles = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch profiles: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchProfiles(page);
}
}, [page, isOpen, fetchProfiles]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Profile</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => fetchProfiles(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-6 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Context</div>
<div className="col-span-1 px-4">Mock Tools</div>
</div>
{profiles.map((p) => (
<div
key={p._id}
className="grid grid-cols-6 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
onClick={() => {
onSelect(p);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{p.name}</div>
<div className="col-span-3 px-4 truncate">{p.context}</div>
<div className="col-span-1 px-4">{p.mockTools ? "Yes" : "No"}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,98 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listScenarios } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { z } from "zod";
interface ScenarioSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (scenario: WithStringId<z.infer<typeof TestScenario>>) => void;
}
export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }: ScenarioSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const fetchScenarios = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch scenarios: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchScenarios(page);
}
}, [page, isOpen, fetchScenarios]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Scenario</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => fetchScenarios(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-5 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Description</div>
</div>
{scenarios.map((s) => (
<div
key={s._id}
className="grid grid-cols-5 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
onClick={() => {
onSelect(s);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{s.name}</div>
<div className="col-span-3 px-4 truncate">{s.description}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,140 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listSimulations } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Chip } from "@nextui-org/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
interface SimulationSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (simulations: WithStringId<z.infer<typeof TestSimulation>>[]) => void;
initialSelected?: WithStringId<z.infer<typeof TestSimulation>>[];
}
export function SimulationSelector({ projectId, isOpen, onOpenChange, onSelect, initialSelected = [] }: SimulationSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>(initialSelected);
const pageSize = 3;
const fetchSimulations = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listSimulations(projectId, page, pageSize);
setSimulations(result.simulations);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch simulations: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchSimulations(page);
}
}, [page, isOpen, fetchSimulations]);
const handleSelect = (simulation: WithStringId<z.infer<typeof TestSimulation>>) => {
const isSelected = selectedSimulations.some(s => s._id === simulation._id);
let newSelected;
if (isSelected) {
newSelected = selectedSimulations.filter(s => s._id !== simulation._id);
} else {
newSelected = [...selectedSimulations, simulation];
}
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
const handleRemove = (simulationId: string) => {
const newSelected = selectedSimulations.filter(s => s._id !== simulationId);
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select Simulations</ModalHeader>
<ModalBody>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => handleRemove(sim._id)}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => fetchSimulations(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{simulations.length === 0 && <div className="text-gray-600 text-center">No simulations found</div>}
{simulations.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-8 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-3 px-4">Name</div>
<div className="col-span-3 px-4">Pass Criteria</div>
<div className="col-span-2 px-4">Last Updated</div>
</div>
{simulations.map((sim) => {
const isSelected = selectedSimulations.some(s => s._id === sim._id);
return (
<div
key={sim._id}
className={`grid grid-cols-8 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer ${
isSelected ? 'bg-blue-50 hover:bg-blue-100' : ''
}`}
onClick={() => handleSelect(sim)}
>
<div className="col-span-3 px-4 truncate">{sim.name}</div>
<div className="col-span-3 px-4 truncate">{sim.passCriteria || '-'}</div>
<div className="col-span-2 px-4 truncate">
<RelativeTime date={new Date(sim.lastUpdatedAt)} />
</div>
</div>
);
})}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Done
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,106 @@
import { WithStringId } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { useCallback, useEffect, useState } from "react";
import { listWorkflows } from "@/app/actions/workflow_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { WorkflowIcon } from "../icons";
import { PublishedBadge } from "@/app/projects/[projectId]/workflow/published_badge";
interface WorkflowSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (workflow: WithStringId<z.infer<typeof Workflow>>) => void;
}
export function WorkflowSelector({ projectId, isOpen, onOpenChange, onSelect }: WorkflowSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<WithStringId<z.infer<typeof Workflow>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const pageSize = 10;
const fetchWorkflows = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listWorkflows(projectId, page, pageSize);
setWorkflows(result.workflows);
setTotalPages(Math.ceil(result.total / pageSize));
setPublishedWorkflowId(result.publishedWorkflowId);
} catch (error) {
setError(`Unable to fetch workflows: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchWorkflows(page);
}
}, [page, isOpen, fetchWorkflows]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Workflow</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => fetchWorkflows(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{workflows.length === 0 && <div className="text-gray-600 text-center">No workflows found</div>}
{workflows.length > 0 && <div className="flex flex-col gap-2">
{workflows.map((workflow) => (
<div
key={workflow._id}
className="flex items-center justify-between p-3 border rounded-md hover:bg-gray-50 cursor-pointer"
onClick={() => {
onSelect(workflow);
onClose();
}}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<WorkflowIcon />
<span className="font-medium">{workflow.name || 'Unnamed workflow'}</span>
{publishedWorkflowId === workflow._id && <PublishedBadge />}
</div>
<div className="text-xs text-gray-500">
Updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
</div>
</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,5 +1,5 @@
import { MongoClient } from "mongodb";
import { PlaygroundChat, Webpage, ChatClientId } from "./types/types";
import { Webpage } from "./types/types";
import { Workflow } from "./types/workflow_types";
import { ApiKey } from "./types/project_types";
import { ProjectMember } from "./types/project_types";
@ -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, SimulationAggregateResult } from "./types/testing_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
import { z } from 'zod';
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017");
@ -20,7 +20,9 @@ export const projectsCollection = db.collection<z.infer<typeof Project>>("projec
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
export const scenariosCollection = db.collection<z.infer<typeof Scenario>>("scenarios");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const simulationRunsCollection = db.collection<z.infer<typeof SimulationRun>>("simulation_runs");
export const simulationResultsCollection = db.collection<z.infer<typeof SimulationResult>>("simulation_results");
export const testScenariosCollection = db.collection<z.infer<typeof TestScenario>>("test_scenarios");
export const testProfilesCollection = db.collection<z.infer<typeof TestProfile>>("test_profiles");
export const testSimulationsCollection = db.collection<z.infer<typeof TestSimulation>>("test_simulations");
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");

View file

@ -10,6 +10,8 @@ export const Project = z.object({
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
defaultTestProfileId: z.string().optional(),
});export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),

View file

@ -1,53 +1,37 @@
import { z } from "zod";
// Base type
export const Scenario = z.object({
export const TestScenario = 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(),
});
// Relevant to new simulation features
export const SimulationScenarioData = z.object({
scenario: z.string(),
context: z.string().default(''),
});
// Legacy
export const SimulationArticleData = z.object({
articleUrl: z.string(),
articleTitle: z.string().default('').optional(),
articleContent: z.string().default('').optional(),
});
export const SimulationChatMessagesData = z.object({
chatMessages: z.string(),
});
// Relevant to new simulation features
export const SimulationData = z.union([
SimulationScenarioData,
SimulationArticleData,
SimulationChatMessagesData
]);
export const SimulationAggregateResult = z.object({
total: z.number(),
pass: z.number(),
fail: z.number(),
});
export const SimulationRun = z.object({
export const TestProfile = z.object({
projectId: z.string(),
scenarioIds: z.array(z.string()),
name: z.string().min(1, "Name cannot be empty"),
context: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
mockTools: z.boolean(),
mockPrompt: z.string().optional(),
});
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
scenarioId: z.string(),
profileId: z.string(),
passCriteria: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestRun = z.object({
projectId: z.string(),
name: z.string(),
simulationIds: z.array(z.string()),
workflowId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed', 'error']),
startedAt: z.string(),
@ -57,12 +41,12 @@ export const SimulationRun = z.object({
pass: z.number(),
fail: z.number(),
}).optional(),
});
});
export const SimulationResult = z.object({
export const TestResult = z.object({
projectId: z.string(),
runId: z.string(),
scenarioId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string()
});

View file

@ -1,7 +1,6 @@
import { CoreMessage, ToolCallPart } from "ai";
import { z } from "zod";
import { apiV1 } from "rowboat-shared";
import { SimulationData } from "./testing_types";
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
@ -9,7 +8,7 @@ export const PlaygroundChat = z.object({
title: z.string().optional(),
messages: z.array(apiV1.ChatMessage),
simulated: z.boolean().default(false).optional(),
simulationData: SimulationData.optional(),
simulationScenario: z.string().optional(),
simulationComplete: z.boolean().default(false).optional(),
agenticState: z.unknown().optional(),
systemMessage: z.string().optional(),
@ -110,6 +109,7 @@ export const ApiRequest = z.object({
skipToolCalls: z.boolean().nullable().optional(),
maxTurns: z.number().nullable().optional(),
workflowId: z.string().nullable().optional(),
testProfileId: z.string().nullable().optional(),
});
export const ApiResponse = z.object({

View file

@ -18,6 +18,7 @@ import { qdrantClient } from "./qdrant";
import { EmbeddingRecord } from "./types/datasource_types";
import { ApiMessage } from "./types/types";
import { openai } from "@ai-sdk/openai";
import { TestProfile } from "./types/testing_types";
export async function callClientToolWebhook(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
@ -220,19 +221,33 @@ export class PrefixLogger {
}
}
export async function mockToolResponse(toolId: string, messages: z.infer<typeof ApiMessage>[]): Promise<string> {
const prompt = `
# Your Specific Task:
Here is a chat between a user and a customer support assistant.
export async function mockToolResponse(toolId: string, messages: z.infer<typeof ApiMessage>[], testProfile: z.infer<typeof TestProfile>): Promise<string> {
const prompt = `Given below is a chat between a user and a customer support assistant.
The assistant has requested a tool call with ID {{toolID}}.
Your job is to come up with an example of the data that the tool call should return.
The current date is {{date}}.
CONVERSATION:
Your job is to come up with the data that the tool call should return.
In order to help you mock the responses, the user has provided some contextual information,
and also some instructions on how to mock the tool call.
>>>CHAT_HISTORY
{{messages}}
<<<END_OF_CHAT_HISTORY
>>>CONTEXT
{{context}}
<<<END_OF_CONTEXT
>>>MOCK_INSTRUCTIONS
{{mockInstructions}}
<<<END_OF_MOCK_INSTRUCTIONS
The current date is {{date}}.
`
.replace('{{toolID}}', toolId)
.replace(`{{date}}`, new Date().toISOString())
.replace('{{context}}', testProfile.context)
.replace('{{mockInstructions}}', testProfile.mockPrompt || '')
.replace('{{messages}}', JSON.stringify(messages.map((m) => {
let tool_calls;
if ('tool_calls' in m && m.role == 'assistant') {

View file

@ -62,11 +62,11 @@ export default function Menu({
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/simulation`}
href={`/projects/${projectId}/test`}
label="Test"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
selected={pathname.startsWith(`/projects/${projectId}/test`)}
/>
{useDataSources && (
<NavLink

View file

@ -4,16 +4,15 @@ import { useEffect, useState, useMemo, useCallback } from "react";
import { z } from "zod";
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 { Chat } from "./chat";
import { useSearchParams, useRouter } from "next/navigation";
import { ActionButton, Pane } from "../workflow/pane";
import { apiV1 } from "rowboat-shared";
import { EllipsisVerticalIcon, MessageSquarePlusIcon, PlayIcon } from "lucide-react";
import { getScenario } from "../../../actions/simulation_actions";
import { getScenario } from "../../../actions/testing_actions";
import clsx from "clsx";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
function SimulateLabel() {
return <span>Simulate<sup className="pl-1">beta</sup></span>;
}
@ -25,18 +24,16 @@ export function App({
projectId,
workflow,
messageSubscriber,
initialTestProfile,
}: {
hidden?: boolean;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
initialTestProfile: z.infer<typeof TestProfile>;
}) {
const searchParams = useSearchParams();
const router = useRouter();
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
const [loadingChat, setLoadingChat] = useState<boolean>(false);
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<z.infer<typeof TestProfile>>(initialTestProfile);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
createdAt: new Date().toISOString(),
@ -45,49 +42,45 @@ export function App({
systemMessage: defaultSystemMessage,
});
const beginSimulation = useCallback((data: z.infer<typeof SimulationData>) => {
setExistingChatId(null);
setLoadingChat(true);
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>>) {
setTestProfile(profile);
setCounter(counter + 1);
setChat({
projectId,
createdAt: new Date().toISOString(),
messages: [],
simulated: true,
simulationData: data,
systemMessage: 'context' in data ? data.context : '',
});
}, [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,
systemMessage: scenario.context || '',
} as z.infer<typeof SimulationScenarioData>);
localStorage.removeItem('pendingScenarioId');
}).catch(error => {
console.error('Error fetching scenario:', error);
localStorage.removeItem('pendingScenarioId');
});
}
}, [projectId, beginSimulation]);
// const beginSimulation = useCallback((scenario: string) => {
// setExistingChatId(null);
// setLoadingChat(true);
// setCounter(counter + 1);
// setChat({
// projectId,
// createdAt: new Date().toISOString(),
// messages: [],
// simulated: true,
// simulationScenario: scenario,
// systemMessage: '',
// });
// }, [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.description);
// 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);
setLoadingChat(true);
setCounter(counter + 1);
setChat({
projectId,
@ -110,27 +103,18 @@ export function App({
>
New chat
</ActionButton>,
<ActionButton
key="simulate"
icon={<PlayIcon size={16} />}
onClick={handleSimulateButtonClick}
>
Simulate
</ActionButton>,
]}
>
<div className="h-full overflow-auto">
{loadingChat && <div className="flex justify-center items-center h-full">
<Spinner />
</div>}
{!loadingChat && <Chat
key={existingChatId || 'chat-' + counter}
<Chat
key={`chat-${counter}`}
chat={chat}
initialChatId={existingChatId || null}
projectId={projectId}
workflow={workflow}
testProfile={testProfile}
messageSubscriber={messageSubscriber}
/>}
onTestProfileChange={handleTestProfileChange}
/>
</div>
</Pane>
);

View file

@ -9,24 +9,28 @@ import { convertWorkflowToAgenticAPI } from "../../../lib/types/agents_api_types
import { AgenticAPIChatRequest } from "../../../lib/types/agents_api_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { ComposeBox } from "./compose-box";
import { Button, Spinner } from "@nextui-org/react";
import { Button, Spinner, Tooltip } from "@nextui-org/react";
import { apiV1 } from "rowboat-shared";
import { CopyAsJsonButton } from "./copy-as-json-button";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileSelector } from "@/app/lib/components/selectors/profile-selector";
import { WithStringId } from "@/app/lib/types/types";
export function Chat({
chat,
initialChatId = null,
projectId,
workflow,
messageSubscriber,
testProfile,
onTestProfileChange,
}: {
chat: z.infer<typeof PlaygroundChat>;
initialChatId?: string | null;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
testProfile: z.infer<typeof TestProfile>;
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>>) => void;
}) {
const [chatId, setChatId] = useState<string | null>(initialChatId);
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [loadingUserResponse, setLoadingUserResponse] = useState<boolean>(false);
@ -34,11 +38,11 @@ export function Chat({
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
last_agent_name: workflow.startAgent,
});
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [systemMessage, setSystemMessage] = useState<string | undefined>(chat.systemMessage);
const [systemMessage, setSystemMessage] = useState<string | undefined>(testProfile.context);
const [isProfileSelectorOpen, setIsProfileSelectorOpen] = useState(false);
// collect published tool call results
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
@ -53,7 +57,7 @@ export function Chat({
role: 'user',
content: prompt,
version: 'v1',
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}];
setMessages(updatedMessages);
@ -64,7 +68,7 @@ export function Chat({
setMessages([...messages, ...results.map((result) => ({
...result,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}))]);
}
@ -97,7 +101,7 @@ export function Chat({
role: 'system',
content: systemMessage || '',
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}, ...messages]),
state: agenticState,
@ -122,7 +126,7 @@ export function Chat({
setMessages([...messages, ...response.messages.map((message) => ({
...message,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}))]);
setAgenticState(response.state);
@ -157,14 +161,14 @@ export function Chat({
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, agenticState, workflow, fetchResponseError, systemMessage, simulationComplete]);
}, [chat.simulated, messages, projectId, agenticState, workflow, fetchResponseError, systemMessage, simulationComplete]);
// simulate user turn
useEffect(() => {
let ignore = false;
async function process() {
if (chat.simulationData === undefined) {
if (chat.simulationScenario === undefined) {
return;
}
@ -172,7 +176,7 @@ export function Chat({
setLoadingUserResponse(true);
try {
const response = await simulateUserResponse(projectId, messages, chat.simulationData)
const response = await simulateUserResponse(projectId, messages, chat.simulationScenario)
if (ignore) {
return;
}
@ -187,7 +191,7 @@ export function Chat({
role: 'user',
content: response,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}]);
setFetchResponseError(null);
@ -226,7 +230,7 @@ export function Chat({
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, simulationComplete, chat.simulationData]);
}, [chat.simulated, messages, projectId, simulationComplete, chat.simulationScenario]);
// save chat on every assistant message
// useEffect(() => {
@ -275,6 +279,22 @@ export function Chat({
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
<CopyAsJsonButton onCopy={handleCopyChat} />
<div className="absolute top-0 left-0">
<Tooltip content={"Change profile"} placement="right">
<button
className="border border-gray-200 dark:border-gray-800 p-2 rounded-lg text-xs hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setIsProfileSelectorOpen(true)}
>
{`Test profile: ${testProfile.name}`}
</button>
</Tooltip>
</div>
<ProfileSelector
projectId={projectId}
isOpen={isProfileSelectorOpen}
onOpenChange={setIsProfileSelectorOpen}
onSelect={onTestProfileChange}
/>
<Messages
projectId={projectId}
messages={messages}
@ -284,6 +304,7 @@ export function Chat({
loadingAssistantResponse={loadingAssistantResponse}
loadingUserResponse={loadingUserResponse}
workflow={workflow}
testProfile={testProfile}
onSystemMessageChange={handleSystemMessageChange}
/>
<div className="shrink-0">

View file

@ -12,6 +12,7 @@ import Link from "next/link";
import { apiV1 } from "rowboat-shared";
import { EditableField } from "../../../lib/components/editable-field";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronsDownIcon, ChevronsRightIcon, ChevronRightIcon, ChevronDownIcon, ExternalLinkIcon, XIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
function UserMessage({ content }: { content: string }) {
return <div className="self-end ml-[30%] flex flex-col">
@ -93,6 +94,7 @@ function ToolCalls({
messages,
sender,
workflow,
testProfile,
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
@ -101,6 +103,7 @@ function ToolCalls({
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile>;
}) {
const resultsMap: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
@ -123,6 +126,7 @@ function ToolCalls({
messages={messages}
sender={sender}
workflow={workflow}
testProfile={testProfile}
/>
})}
</div>;
@ -136,6 +140,7 @@ function ToolCall({
messages,
sender,
workflow,
testProfile,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
@ -144,6 +149,7 @@ function ToolCall({
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile>;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
@ -154,15 +160,6 @@ function ToolCall({
}
switch (toolCall.function.name) {
case 'retrieve_url_info':
return <RetrieveUrlInfoToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
case 'getArticleInfo':
return <GetInformationToolCall
toolCall={toolCall}
@ -184,7 +181,7 @@ function ToolCall({
sender={sender}
/>;
}
if (matchingWorkflowTool && !matchingWorkflowTool.mockInPlayground) {
if (matchingWorkflowTool && !testProfile.mockTools) {
return <ClientToolCall
toolCall={toolCall}
result={result}
@ -202,6 +199,7 @@ function ToolCall({
messages={messages}
sender={sender}
autoSubmit={matchingWorkflowTool?.autoSubmitMockedResponse}
testProfile={testProfile}
/>;
}
}
@ -310,85 +308,6 @@ function GetInformationToolCall({
</div>;
}
function RetrieveUrlInfoToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const args = JSON.parse(toolCall.function.arguments) as { url: string };
let typedResult: z.infer<typeof WebpageCrawlResponse> | undefined;
if (result) {
typedResult = JSON.parse(result.content) as z.infer<typeof WebpageCrawlResponse>;
}
useEffect(() => {
if (result) {
return;
}
let ignore = false;
function process() {
// parse args
scrapeWebpage(args.url)
.then(page => {
if (ignore) {
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(page),
};
setResult(result);
handleResult(result);
});
}
process();
return () => {
ignore = true;
};
}, [result, toolCall.id, toolCall.function.name, projectId, args.url, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<ToolCallHeader toolCall={toolCall} result={result} />
<div className='mt-1 flex flex-col gap-2'>
<div className="flex gap-1">
URL: <a className="inline-flex items-center gap-1" target="_blank" href={args.url}>
<span className='underline'>
{args.url}
</span>
<ExternalLinkIcon size={16} />
</a>
</div>
{result && (
<ExpandableContent
label='Content'
content={typedResult}
expanded={false}
/>
)}
</div>
</div>
</div>;
}
function TransferToAgentToolCall({
toolCall,
result: availableResult,
@ -495,6 +414,7 @@ function MockToolCall({
messages,
sender,
autoSubmit = false,
testProfile,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
@ -503,6 +423,7 @@ function MockToolCall({
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
autoSubmit?: boolean;
testProfile: z.infer<typeof TestProfile>;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const [response, setResponse] = useState('');
@ -538,7 +459,7 @@ function MockToolCall({
async function process() {
setGeneratingResponse(true);
const response = await suggestToolResponse(toolCall.id, projectId, messages);
const response = await suggestToolResponse(toolCall.id, projectId, messages, testProfile);
if (ignore) {
return;
}
@ -550,7 +471,7 @@ function MockToolCall({
return () => {
ignore = true;
};
}, [result, response, toolCall.id, projectId, messages]);
}, [result, response, toolCall.id, projectId, messages, testProfile]);
// auto submit if autoSubmitMockedResponse is true
useEffect(() => {
@ -682,6 +603,7 @@ export function Messages({
loadingAssistantResponse,
loadingUserResponse,
workflow,
testProfile,
onSystemMessageChange,
}: {
projectId: string;
@ -692,13 +614,12 @@ export function Messages({
loadingAssistantResponse: boolean;
loadingUserResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile>;
onSystemMessageChange: (message: string) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
const systemMessageLocked = messages.length > 0;
// scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@ -707,9 +628,9 @@ export function Messages({
return <div className="grow pt-4 overflow-auto">
<div className="max-w-[768px] mx-auto flex flex-col gap-8">
<SystemMessage
content={systemMessage || ''}
content={testProfile.context}
onChange={onSystemMessageChange}
locked={systemMessageLocked}
locked={true}
/>
{messages.map((message, index) => {
if (message.role === 'assistant') {
@ -723,6 +644,7 @@ export function Messages({
messages={messages}
sender={message.agenticSender}
workflow={workflow}
testProfile={testProfile}
/>;
} else {
// the assistant message createdAt is an ISO string timestamp

View file

@ -1,459 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import {
getScenarios,
createScenario,
updateScenario,
deleteScenario,
getRuns,
getRun,
getRunResults,
createRun,
createRunResult,
updateRunStatus,
createAggregateResult,
deleteRun,
} from '../../../actions/simulation_actions';
import { type WithStringId } from '../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { z } from 'zod';
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
import { ScenarioList, ScenarioViewer } from './components/ScenarioComponents';
import { fetchWorkflow } from '../../../actions/workflow_actions';
import { StructuredPanel, ActionButton } from "../../../lib/components/structured-panel";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { Pagination } from "../../../lib/components/pagination";
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
type SimulationReport = {
totalScenarios: number;
passedScenarios: number;
failedScenarios: number;
results: z.infer<typeof SimulationResult>[];
timestamp: Date;
};
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;
const result: z.infer<typeof SimulationResult> = {
projectId: projectId,
runId: runId,
scenarioId: scenario._id,
result: passed ? 'pass' : 'fail' as const,
details: passed
? "The bot successfully completed the conversation"
: "The bot could not handle the conversation",
};
await createRunResult(
projectId,
runId,
scenario._id,
result.result,
result.details
);
return result;
};
export default function SimulationApp() {
const { projectId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
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());
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[]>>({});
const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [menuOpenId, setMenuOpenIdState] = useState<string | null>(null);
const runsPerPage = 10;
const currentPage = Number(searchParams.get('page')) || 1;
const setMenuOpenId = useCallback((id: string | null) => {
setMenuOpenIdState(id);
}, []);
// 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]);
// 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(
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;
// First verify the scenario exists and get its current state
const currentScenarios = await getScenarios(projectId as string);
const existingScenario = currentScenarios.find(s => s._id === updatedScenario._id);
if (!existingScenario) {
console.error('Scenario not found');
return;
}
// Only update the specific fields that have changed
await updateScenario(
projectId as string,
updatedScenario._id,
{
name: updatedScenario.name,
description: updatedScenario.description,
criteria: updatedScenario.criteria,
context: updatedScenario.context,
}
);
// Just refresh the scenarios list without setting selected scenario
const updatedScenarios = await getScenarios(projectId as string);
setScenarios(updatedScenarios);
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 () => {
if (!projectId) return;
setIsRunning(true);
setSimulationReport(null);
try {
// Get workflowId from localStorage
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
throw new Error('No workflow selected. Please select a workflow first.');
}
// First verify the workflow exists before creating the run
let workflow;
try {
workflow = await fetchWorkflow(projectId as string, workflowId);
} catch (error) {
// If workflow doesn't exist, clear localStorage and throw error
localStorage.removeItem(`lastWorkflowId_${projectId}`);
throw new Error('Selected workflow no longer exists. Please select a new workflow.');
}
const newRun = await createRun(
projectId as string,
scenarios.map(s => s._id),
workflowId
);
setActiveRun(newRun);
// Store workflow version
setWorkflowVersions(prev => ({
...prev,
[workflowId]: workflow
}));
const shouldMock = process.env.NEXT_PUBLIC_MOCK_SIMULATION_RESULTS === 'true';
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);
}
await fetchRuns();
} catch (error) {
console.error('Error starting scenarios:', error);
alert(error instanceof Error ? error.message : 'An error occurred while starting scenarios');
} 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);
};
// Update the workflow versions fetching effect
useEffect(() => {
if (!projectId || !runs.length) return;
const fetchWorkflowVersions = async () => {
const workflowIds = Array.from(new Set(runs.map(run => run.workflowId)));
const versions: Record<string, WithStringId<z.infer<typeof Workflow>>> = {};
for (const workflowId of workflowIds) {
try {
const workflow = await fetchWorkflow(projectId as string, workflowId);
versions[workflowId] = workflow;
} catch (error) {
console.error(`Error fetching workflow ${workflowId}:`, error);
// Add a placeholder for deleted/invalid workflows
versions[workflowId] = {
_id: workflowId,
name: "Deleted/Invalid Workflow",
projectId: projectId as string,
agents: [],
prompts: [],
tools: [],
startAgent: "",
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
}
}
setWorkflowVersions(versions);
};
fetchWorkflowVersions();
}, [projectId, runs]);
const handleCancelRun = async (runId: string) => {
if (!projectId) return;
try {
await updateRunStatus(projectId as string, runId, 'cancelled');
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error cancelling run:', error);
}
};
const handleDeleteRun = async (runId: string) => {
if (!projectId) return;
try {
await deleteRun(projectId as string, runId);
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error deleting run:', error);
}
};
const indexOfLastRun = currentPage * runsPerPage;
const indexOfFirstRun = indexOfLastRun - runsPerPage;
const currentRuns = runs.slice(indexOfFirstRun, indexOfLastRun);
const totalPages = Math.ceil(runs.length / runsPerPage);
return (
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<ScenarioList
scenarios={scenarios}
selectedId={selectedScenario?._id ?? null}
onSelect={(id) => setSelectedScenario(scenarios.find(s => s._id === id) ?? null)}
onAdd={createNewScenario}
onRunScenario={(id) => {
const scenario = scenarios.find(s => s._id === id);
if (scenario) runSingleScenario(scenario);
}}
onDeleteScenario={(id) => handleDeleteScenario(id)}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={20} defaultSize={85} className="overflow-auto">
{selectedScenario ? (
<ScenarioViewer
scenario={selectedScenario}
onSave={handleUpdateScenario}
onClose={handleCloseScenario}
/>
) : (
<StructuredPanel
title="SIMULATION RUNS"
tooltip="Run and view simulations"
actions={[
<ActionButton
key="run-all"
onClick={() => void runAllScenarios()}
disabled={isRunning}
icon={<PlayIcon className="w-4 h-4" />}
primary
>
Run All Scenarios
</ActionButton>
]}
>
<div className="p-6">
{/* Runs list */}
{isLoadingRuns ? (
<div>Loading runs...</div>
) : (
<div className="space-y-4">
{currentRuns.map((run) => (
<SimulationResultCard
key={run._id}
run={run}
results={allRunResults[run._id] || []}
scenarios={scenarios}
workflow={workflowVersions[run.workflowId]}
onCancelRun={handleCancelRun}
onDeleteRun={handleDeleteRun}
menuOpenId={menuOpenId}
setMenuOpenId={setMenuOpenId}
/>
))}
</div>
)}
{/* Pagination */}
{runs.length > runsPerPage && (
<div className="flex justify-center mt-4">
<Pagination
total={totalPages}
page={currentPage}
/>
</div>
)}
</div>
</StructuredPanel>
)}
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -1,399 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { ChevronDownIcon, ChevronRightIcon, NoSymbolIcon, EllipsisVerticalIcon, ArrowDownTrayIcon, TrashIcon } 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 { Workflow } from "../../../../lib/types/workflow_types";
import { clsx } from 'clsx';
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[];
workflow?: WithStringId<z.infer<typeof Workflow>>;
onCancelRun?: (runId: string) => void;
onDeleteRun?: (runId: string) => Promise<void>;
menuOpenId: string | null;
setMenuOpenId: (id: string | null) => void;
}
export const SimulationResultCard = ({ run, results, scenarios, workflow, onCancelRun, onDeleteRun, menuOpenId, setMenuOpenId }: SimulationResultCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [expandedScenarios, setExpandedScenarios] = useState<Set<string>>(new Set());
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const totalScenarios = run.aggregateResults?.total ?? run.scenarioIds.length;
const passedScenarios = run.aggregateResults?.pass ?? 0;
const failedScenarios = run.aggregateResults?.fail ?? 0;
const getStatusClass = (status: string) => {
const baseClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
switch (status) {
case 'completed':
case 'pass':
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-400`;
case 'failed':
case 'fail':
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400`;
case 'error':
return `${baseClass} bg-orange-50 dark:bg-orange-900/20 text-orange-800 dark:text-orange-400`;
case 'cancelled':
return `${baseClass} bg-gray-50 dark:bg-neutral-800 text-gray-800 dark:text-neutral-400`;
case 'running':
case 'pending':
default:
return `${baseClass} bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400`;
}
};
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;
});
};
useEffect(() => {
if (menuOpenId) {
const closeMenu = () => setMenuOpenId(null);
window.addEventListener('click', closeMenu);
return () => window.removeEventListener('click', closeMenu);
}
}, [menuOpenId, setMenuOpenId]);
return (
<div className="border dark:border-neutral-800 rounded-lg mb-4 shadow-sm">
<div
className={clsx(
"p-4 flex items-center justify-between cursor-pointer",
"transition-colors duration-200",
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
"border-b border-transparent",
isExpanded && "border-b-neutral-200 dark:border-b-neutral-800"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2">
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
) : (
<ChevronRightIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
)}
<div className="text-sm truncate dark:text-neutral-200">
{formatMainTitle(run.startedAt)}
</div>
</div>
<div className="flex items-center gap-2">
<span className={getStatusClass(run.status)}>
{run.status}
</span>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpenId(menuOpenId === run._id ? null : run._id);
}}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600 dark:text-neutral-400" />
</button>
{menuOpenId === run._id && (
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white dark:bg-neutral-900 ring-1 ring-black ring-opacity-5 dark:ring-neutral-700 z-10">
<div className="py-1">
{(run.status === 'running' || run.status === 'pending') && onCancelRun && (
<button
onClick={(e) => {
e.stopPropagation();
onCancelRun(run._id);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
>
<NoSymbolIcon className="h-4 w-4 mr-2" />
Cancel Run
</button>
)}
<button
disabled
className="flex items-center px-4 py-2 text-sm text-gray-400 dark:text-neutral-500 w-full cursor-not-allowed whitespace-nowrap"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
Download transcripts
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete run
</button>
</div>
</div>
)}
</div>
</div>
</div>
{isExpanded && (
<div className="p-4 border-t dark:border-neutral-800">
{run.status === 'error' ? (
<div className="text-orange-800 bg-orange-50 p-4 rounded-lg">
Your simulation could not be completed. Please run a new simulation again.
</div>
) : (
<>
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4 mb-6">
{workflow && (
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Workflow Version</div>
<div className="font-medium dark:text-neutral-200">{workflow.name}</div>
</div>
)}
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Completed</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ? formatDateTime(run.completedAt) : 'Not completed'}
</div>
</div>
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Duration</div>
<div className="text-sm dark:text-neutral-300">{getDuration()}</div>
</div>
</div>
{/* Results statistics */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-gray-50 dark:bg-neutral-800">
<div className="text-sm text-gray-600 dark:text-neutral-400">Total Scenarios</div>
<div className="text-2xl font-semibold dark:text-neutral-200">{totalScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-sm text-green-600 dark:text-green-400">Passed</div>
<div className="text-2xl font-semibold text-green-700 dark:text-green-400">{passedScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-sm text-red-600 dark:text-red-400">Failed</div>
<div className="text-2xl font-semibold text-red-700 dark:text-red-400">{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={clsx(
"border dark:border-neutral-800 rounded-lg overflow-hidden",
"transition-colors duration-200",
result?.result === 'pass'
? 'bg-green-50/50 dark:bg-green-900/10 border-green-200 dark:border-green-900/50'
: result?.result === 'fail'
? 'bg-red-50/50 dark:bg-red-900/10 border-red-200 dark:border-red-900/50'
: 'bg-gray-50/50 dark:bg-neutral-900/50 border-gray-200 dark:border-neutral-800'
)}
>
<div
className={clsx(
"p-3 flex items-center justify-between cursor-pointer",
"hover:bg-white/50 dark:hover:bg-neutral-800/50"
)}
onClick={(e) => toggleScenario(scenarioId, e)}
>
<div className="flex items-center space-x-2">
{isScenarioExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
)}
<span className="font-medium text-gray-900 dark:text-neutral-200">{scenario.name}</span>
</div>
{result && (
<span className={getStatusClass(result.result)}>
{result.result}
</span>
)}
</div>
{isScenarioExpanded && (
<div className="p-3 border-t border-opacity-50 dark:border-neutral-800 space-y-4">
<div>
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Description</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.description}
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Criteria</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.criteria || 'No criteria specified'}
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Context</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.context || 'No context provided'}
</div>
</div>
{result && (
<div>
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Result Details</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{result.details}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
)}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<h3 className="text-lg leading-6 font-medium text-gray-900 whitespace-nowrap">
Are you sure you want to delete this run?
</h3>
<div className="mt-6 flex justify-center space-x-4">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-white text-gray-600 text-sm font-medium border rounded-md hover:bg-gray-50"
>
Retain
</button>
<button
onClick={async () => {
if (onDeleteRun) {
await onDeleteRun(run._id);
setShowDeleteConfirm(false);
}
}}
className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700"
>
Delete
</button>
</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>
);
};

View file

@ -1,204 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Save, EllipsisVerticalIcon, PlayIcon, TrashIcon, X } from "lucide-react";
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 { FormSection } from '../../../../lib/components/form-section';
import { StructuredPanel, ActionButton } from "../../../../lib/components/structured-panel";
import clsx from "clsx";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
import { SectionHeader, ListItem } from "../../../../lib/components/structured-list";
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 [isDirty, setIsDirty] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
useEffect(() => {
setEditedScenario(scenario);
setIsDirty(false);
}, [scenario]);
const handleChange = useCallback((field: keyof ScenarioType, value: string) => {
if (field === 'name') {
setNameError(value.trim() ? null : 'Name is required');
}
setEditedScenario(prev => ({
...prev,
[field]: value,
}));
setIsDirty(true);
}, []);
const handleSave = useCallback(() => {
if (!editedScenario.name.trim()) {
setNameError('Name is required');
return;
}
onSave(editedScenario);
onClose();
}, [editedScenario, onSave, onClose]);
return (
<StructuredPanel
title="SCENARIO DETAILS"
actions={[
isDirty && (
<ActionButton
key="save"
onClick={handleSave}
icon={<Save className="w-4 h-4" />}
primary
>
Save
</ActionButton>
),
<ActionButton
key="close"
onClick={onClose}
icon={<X className="w-4 h-4" />}
>
Close
</ActionButton>
].filter(Boolean)}
>
<div className="flex flex-col gap-4">
<FormSection label="Name" showDivider>
<EditableField
value={editedScenario.name}
onChange={(value) => handleChange('name', value)}
multiline={false}
className="w-full"
showSaveButton={false}
placeholder="Enter an identifiable scenario name"
error={nameError}
/>
</FormSection>
<FormSection label="Description" showDivider>
<EditableField
value={editedScenario.description}
onChange={(value) => handleChange('description', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Describe the user scenario to be simulated"
/>
</FormSection>
<FormSection label="Criteria" showDivider>
<EditableField
value={editedScenario.criteria}
onChange={(value) => handleChange('criteria', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Enter success criteria for this scenario to pass in a simulation"
/>
</FormSection>
<FormSection label="Context">
<EditableField
value={editedScenario.context}
onChange={(value) => handleChange('context', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Provide context about the user to the assistant at the start of chat"
/>
</FormSection>
</div>
</StructuredPanel>
);
}
function ScenarioDropdown({
name,
onRun,
onDelete,
}: {
name: string;
onRun: () => void;
onDelete: () => void;
}) {
return (
<Dropdown>
<DropdownTrigger>
<EllipsisVerticalIcon size={16} />
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'run') onRun();
if (key === 'delete') onDelete();
}}
>
<DropdownItem
key="run"
startContent={<PlayIcon className="w-4 h-4" />}
>
Run scenario
</DropdownItem>
<DropdownItem
key="delete"
className="text-danger"
startContent={<TrashIcon className="w-4 h-4" />}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
}
export function ScenarioList({
scenarios,
selectedId,
onSelect,
onAdd,
onRunScenario,
onDeleteScenario,
}: {
scenarios: ScenarioType[];
selectedId: string | null;
onSelect: (id: string) => void;
onAdd: () => void;
onRunScenario: (id: string) => void;
onDeleteScenario: (id: string) => void;
}) {
return (
<StructuredPanel
title="TESTS"
tooltip="Browse and manage your test scenarios"
>
<div className="overflow-auto flex flex-col gap-1 justify-start">
<SectionHeader title="Scenarios" onAdd={onAdd} />
{scenarios.map((scenario) => (
<ListItem
key={scenario._id}
name={scenario.name}
isSelected={selectedId === scenario._id}
onClick={() => onSelect(scenario._id)}
rightElement={
<ScenarioDropdown
name={scenario.name}
onRun={() => onRunScenario(scenario._id)}
onDelete={() => onDeleteScenario(scenario._id)}
/>
}
/>
))}
</div>
</StructuredPanel>
);
}

View file

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

View file

@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ScenariosApp } from "./scenarios_app";
import { ProfilesApp } from "./profiles_app";
import { SimulationsApp } from "./simulations_app";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { RunsApp } from "./runs_app";
export function App({
projectId,
slug
}: {
projectId: string,
slug?: string[]
}) {
const router = useRouter();
const pathname = usePathname();
let selection: "scenarios" | "profiles" | "criteria" | "simulations" | "runs" = "runs";
if (!slug || slug.length === 0) {
router.push(`/projects/${projectId}/test/runs`);
} else if (slug[0] === "scenarios") {
selection = "scenarios";
} else if (slug[0] === "profiles") {
selection = "profiles";
} else if (slug[0] === "criteria") {
selection = "criteria";
} else if (slug[0] === "simulations") {
selection = "simulations";
} else if (slug[0] === "runs") {
selection = "runs";
}
let innerSlug: string[] = [];
if (slug && slug.length > 1) {
innerSlug = slug.slice(1);
}
const menuItems = [
{ label: "Scenarios", href: `/projects/${projectId}/test/scenarios` },
{ label: "Profiles", href: `/projects/${projectId}/test/profiles` },
{ label: "Simulations", href: `/projects/${projectId}/test/simulations` },
{ label: "Test Runs", href: `/projects/${projectId}/test/runs` },
];
return <div className="flex h-full">
<div className="w-40 shrink-0 p-2">
<ul>
{menuItems.map((item) => (
<li key={item.label}>
<Link
className={cn(
"block p-2 rounded-md text-sm",
pathname.startsWith(item.href) ? "bg-gray-100" : "hover:bg-gray-100"
)}
href={item.href}>{item.label}</Link>
</li>
))}
</ul>
</div>
<div className="grow border-l border-gray-200 p-2">
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
{selection === "profiles" && <ProfilesApp projectId={projectId} slug={innerSlug} />}
{selection === "simulations" && <SimulationsApp projectId={projectId} slug={innerSlug} />}
{selection === "runs" && <RunsApp projectId={projectId} slug={innerSlug} />}
</div>
</div>;
}

View file

@ -0,0 +1,8 @@
import { App } from "./app";
export default function Page({ params }: { params: { projectId: string, slug?: string[] } }) {
return <App
projectId={params.projectId}
slug={params.slug}
/>;
}

View file

@ -0,0 +1,535 @@
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile, setDefaultProfile } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Switch, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Tooltip } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon, ArrowLeftIcon, StarIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { RelativeTime } from "@primer/react"
import { getProjectConfig } from "@/app/actions/project_actions";
function EditProfile({
projectId,
profileId,
}: {
projectId: string,
profileId: string,
}) {
const router = useRouter();
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [loading, setLoading] = useState(true);
const [mockTools, setMockTools] = useState(false);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
async function fetchProfile() {
setError(null);
try {
const profile = await getProfile(projectId, profileId);
setProfile(profile);
setMockTools(profile?.mockTools || false);
} catch (error) {
setError(`Unable to fetch profile: ${error}`);
} finally {
setLoading(false);
}
}
fetchProfile();
}, [profileId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockPrompt = formData.get("mockPrompt") as string;
await updateProfile(projectId, profileId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
});
router.push(`/projects/${projectId}/test/profiles/${profileId}`);
} catch (error) {
setError(`Unable to update profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Edit Profile</h1>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
{!loading && profile && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
defaultValue={profile.name}
required
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
defaultValue={profile.context}
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
defaultValue={profile.mockPrompt}
/>}
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}`}
>
Cancel
</Button>
</div>
</form>
)}
</div>;
}
function ViewProfile({
projectId,
profileId,
}: {
projectId: string,
profileId: string,
}) {
const router = useRouter();
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [defaultProfileId, setDefaultProfileId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
async function fetchProfile() {
const profile = await getProfile(projectId, profileId);
const projectConfig = await getProjectConfig(projectId);
setProfile(profile);
setDefaultProfileId(projectConfig.defaultTestProfileId || null);
setLoading(false);
}
fetchProfile();
}, [projectId, profileId]);
async function handleDelete() {
try {
await deleteProfile(projectId, profileId);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setDeleteError(`Failed to delete profile: ${error}`);
}
}
async function handleSetDefault() {
await setDefaultProfile(projectId, profileId);
router.push(`/projects/${projectId}/test/profiles`);
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">View Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Profiles
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !profile && <div className="text-gray-600 text-center">Profile not found</div>}
{!loading && profile && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Name</div>
<div className="flex-[2]">
<div className="flex flex-col gap-2">
<div>{profile.name}</div>
{defaultProfileId === profile._id && <div className="flex items-center gap-2">
<StarIcon className="w-4 h-4" />
<div className="text-gray-600">This is the default profile</div>
</div>}
</div>
</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Context</div>
<div className="flex-[2] whitespace-pre-wrap">{profile.context}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Mock Tools</div>
<div className="flex-[2]">{profile.mockTools ? "Yes" : "No"}</div>
</div>
{profile.mockPrompt && <div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Mock Prompt</div>
<div className="flex-[2] whitespace-pre-wrap">{profile.mockPrompt}</div>
</div>}
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Created</div>
<div className="flex-[2]"><RelativeTime date={new Date(profile.createdAt)} /></div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Last Updated</div>
<div className="flex-[2]"><RelativeTime date={new Date(profile.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}/edit`}
>
Edit
</Button>
{defaultProfileId !== profile._id && <Button
size="sm"
color="danger"
variant="flat"
onClick={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>}
{defaultProfileId !== profile._id && <Button
size="sm"
onClick={() => handleSetDefault()}
startContent={<StarIcon className="w-4 h-4" />}
>
Set as default profile
</Button>}
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this profile?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
}
function NewProfile({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [mockTools, setMockTools] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockPrompt = formData.get("mockPrompt") as string;
const profile = await createProfile(projectId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
});
router.push(`/projects/${projectId}/test/profiles/${profile._id}`);
} catch (error) {
setError(`Unable to create profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Profiles
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
required
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
/>}
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
}
function ProfileList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [defaultProfileId, setDefaultProfileId] = useState<string | null>(null);
const [total, setTotal] = useState(0);
useEffect(() => {
let ignore = false;
async function fetchProfiles() {
setLoading(true);
setError(null);
try {
const profiles = await listProfiles(projectId, page, pageSize);
const projectConfig = await getProjectConfig(projectId);
if (!ignore) {
setProfiles(profiles.profiles);
setTotal(Math.ceil(profiles.total / pageSize));
setDefaultProfileId(projectConfig.defaultTestProfileId || null);
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch profiles: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchProfiles();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Profiles</h1>
<Button
size="sm"
onClick={() => router.push(`/projects/${projectId}/test/profiles/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Profile
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-8 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Context</div>
<div className="col-span-1 px-4">Mock Tools</div>
<div className="col-span-1 px-4">Created</div>
<div className="col-span-1 px-4">Updated</div>
</div>
{/* Rows */}
{profiles.map((profile) => (
<div key={profile._id} className="grid grid-cols-8 py-2 border-b hover:bg-gray-50 text-sm">
<div className="col-span-2 px-4 truncate">
<div className="flex items-center gap-2">
<Link
href={`/projects/${projectId}/test/profiles/${profile._id}`}
className="text-blue-600 hover:underline"
>
{profile.name}
</Link>
{defaultProfileId === profile._id && <Tooltip content="Default Profile">
<StarIcon className="w-4 h-4" />
</Tooltip>}
</div>
</div>
<div className="col-span-3 px-4 truncate">{profile.context}</div>
<div className="col-span-1 px-4">{profile.mockTools ? "Yes" : "No"}</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(profile.createdAt)} />
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(profile.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/profiles?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
}
export function ProfilesApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let profileId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
selection = "edit";
profileId = slug[0];
} else {
selection = "view";
profileId = slug[0];
}
}
return <>
{selection === "list" && <ProfileList projectId={projectId} />}
{selection === "new" && <NewProfile projectId={projectId} />}
{selection === "view" && profileId && <ViewProfile projectId={projectId} profileId={profileId} />}
{selection === "edit" && profileId && <EditProfile projectId={projectId} profileId={profileId} />}
</>;
}

View file

@ -0,0 +1,456 @@
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createRun, getRun, getSimulation, listRuns } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Chip } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon, WorkflowIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { RelativeTime } from "@primer/react"
import { SimulationSelector } from "@/app/lib/components/selectors/simulation-selector";
import { WorkflowSelector } from "@/app/lib/components/selectors/workflow-selector";
import { Workflow } from "@/app/lib/types/workflow_types";
import { fetchWorkflow } from "@/app/actions/workflow_actions";
function NewRun({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [isSimulationSelectorOpen, setIsSimulationSelectorOpen] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [isWorkflowSelectorOpen, setIsWorkflowSelectorOpen] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
const simulationIds = selectedSimulations.map(sim => sim._id);
if (!selectedWorkflow) {
setError("Please select a workflow");
return;
}
if (simulationIds.length === 0) {
setError("Please select at least one simulation");
return;
}
try {
const run = await createRun(projectId, {
workflowId: selectedWorkflow._id,
simulationIds
});
router.push(`/projects/${projectId}/test/runs/${run._id}`);
} catch (error) {
setError(`Unable to create run: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Run</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Runs
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Workflow</label>
<div className="flex items-center gap-2">
{selectedWorkflow ? (
<div className="text-sm text-blue-600">{selectedWorkflow.name}</div>
) : (
<div className="text-sm text-gray-500">No workflow selected</div>
)}
<Button
size="sm"
onClick={() => setIsWorkflowSelectorOpen(true)}
type="button"
>
{selectedWorkflow ? "Change" : "Select"} Workflow
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
onClick={() => setIsSimulationSelectorOpen(true)}
type="button"
className="self-start"
>
Select Simulations
</Button>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => setSelectedSimulations(prev => prev.filter(s => s._id !== sim._id))}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
</div>
<FormStatusButton
props={{
className: "self-start",
children: "Create Run",
size: "sm",
type: "submit",
isDisabled: !selectedWorkflow || selectedSimulations.length === 0,
}}
/>
</form>
<SimulationSelector
projectId={projectId}
isOpen={isSimulationSelectorOpen}
onOpenChange={setIsSimulationSelectorOpen}
onSelect={setSelectedSimulations}
initialSelected={selectedSimulations}
/>
<WorkflowSelector
projectId={projectId}
isOpen={isWorkflowSelectorOpen}
onOpenChange={setIsWorkflowSelectorOpen}
onSelect={setSelectedWorkflow}
/>
</div>;
}
function ViewRun({
projectId,
runId,
}: {
projectId: string,
runId: string,
}) {
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
const [loading, setLoading] = useState(true);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
useEffect(() => {
async function fetchRun() {
const run = await getRun(projectId, runId);
setRun(run);
if (run) {
// Fetch workflow and simulations in parallel
const [workflowResult, simulationsResult] = await Promise.all([
fetchWorkflow(projectId, run.workflowId),
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
]);
setWorkflow(workflowResult);
setSimulations(simulationsResult.filter(s => s !== null));
}
setLoading(false);
}
fetchRun();
}, [runId, projectId]);
return <div className="h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Runs
</Button>
</div>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
{!loading && run && (
<>
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4">
{workflow && (
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Workflow Version</div>
<div className="font-medium dark:text-neutral-200">{workflow.name}</div>
</div>
)}
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Completed</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ? <RelativeTime date={new Date(run.completedAt)} /> : 'Not completed'}
</div>
</div>
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Duration</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ?
`${((new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime()) / 1000).toFixed(1)}s` :
'In Progress'}
</div>
</div>
</div>
{/* Results statistics */}
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-gray-50 dark:bg-neutral-800">
<div className="text-sm text-gray-600 dark:text-neutral-400">Total Tests</div>
<div className="text-2xl font-semibold dark:text-neutral-200">{run.aggregateResults?.total || 0}</div>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-sm text-green-600 dark:text-green-400">Passed</div>
<div className="text-2xl font-semibold text-green-700 dark:text-green-400">{run.aggregateResults?.pass || 0}</div>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-sm text-red-600 dark:text-red-400">Failed</div>
<div className="text-2xl font-semibold text-red-700 dark:text-red-400">{run.aggregateResults?.fail || 0}</div>
</div>
</div>
{/* Simulations List */}
<div className="mt-4">
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
<div className="space-y-2">
{simulations.map(sim => (
<div key={sim._id} className="border dark:border-neutral-800 rounded-lg p-3">
<Link
href={`/projects/${projectId}/test/simulations/${sim._id}`}
className="text-blue-600 hover:underline"
>
{sim.name}
</Link>
</div>
))}
</div>
</div>
</>
)}
</div>;
}
function RunList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [total, setTotal] = useState(0);
useEffect(() => {
let ignore = false;
async function fetchRuns() {
setLoading(true);
setError(null);
try {
const result = await listRuns(projectId, page, pageSize);
if (!ignore) {
setRuns(result.runs);
setTotal(Math.ceil(result.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch runs: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchRuns();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
useEffect(() => {
let ignore = false;
async function resolveWorkflows() {
const workflowIds = runs.reduce((acc, run) => {
if (!acc.includes(run.workflowId)) {
acc.push(run.workflowId);
}
return acc;
}, [] as string[]);
const workflows = await Promise.all(workflowIds.map((workflowId) => fetchWorkflow(projectId, workflowId)));
if (ignore) {
return;
}
setWorkflowMap(workflows.filter((workflow) => workflow !== null).reduce((acc, workflow) => {
acc[workflow._id] = workflow;
return acc;
}, {} as Record<string, WithStringId<z.infer<typeof Workflow>>>));
}
if (error == null) {
resolveWorkflows();
}
return () => {
ignore = true;
};
}, [runs, error, projectId]);
return <div className="h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">Test Runs</h1>
<Button
size="sm"
onClick={() => router.push(`/projects/${projectId}/test/runs/new`)}
startContent={<PlusIcon className="w-4 h-4" />}
>
New Run
</Button>
</div>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{runs.length === 0 && <div className="text-gray-600 text-center">No test runs found</div>}
{runs.length > 0 && <div className="space-y-4">
{runs.map((run) => (
<div key={run._id} className="border dark:border-neutral-800 rounded-lg shadow-sm">
<div className="p-4 flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800">
<div className="flex items-center space-x-4">
<Link
href={`/projects/${projectId}/test/runs/${run._id}`}
className="text-blue-600 hover:underline"
>
{run.name}
</Link>
{workflowMap[run.workflowId] && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-neutral-400">
<WorkflowIcon className="w-4 h-4 shrink-0" />
{workflowMap[run.workflowId].name}
</div>
)}
</div>
<div className="flex items-center gap-4">
<span className={getStatusClass(run.status)}>
{run.status}
</span>
<div className="text-sm text-gray-600 dark:text-neutral-400">
<RelativeTime date={new Date(run.startedAt)} />
</div>
</div>
</div>
{run.aggregateResults && (
<div className="border-t dark:border-neutral-800 px-4 py-2 bg-gray-50 dark:bg-neutral-900/50">
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-gray-600 dark:text-neutral-400">
Total: {run.aggregateResults.total}
</div>
<div className="text-green-600 dark:text-green-400">
Passed: {run.aggregateResults.pass}
</div>
<div className="text-red-600 dark:text-red-400">
Failed: {run.aggregateResults.fail}
</div>
</div>
</div>
)}
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/runs?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
}
// Helper function for status styling
function getStatusClass(status: string) {
const baseClass = "px-2 py-1 rounded text-xs uppercase font-medium";
switch (status) {
case 'completed':
return `${baseClass} bg-green-100 text-green-800`;
case 'failed':
case 'error':
return `${baseClass} bg-red-100 text-red-800`;
case 'cancelled':
return `${baseClass} bg-gray-100 text-gray-800`;
case 'running':
case 'pending':
default:
return `${baseClass} bg-yellow-100 text-yellow-800`;
}
}
export function RunsApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" = "list";
let runId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else {
selection = "view";
runId = slug[0];
}
}
return <>
{selection === "list" && <RunList projectId={projectId} />}
{selection === "new" && <NewRun projectId={projectId} />}
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
</>;
}

View file

@ -0,0 +1,463 @@
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createScenario, getScenario, listScenarios, updateScenario, deleteScenario } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { RelativeTime } from "@primer/react"
function EditScenario({
projectId,
scenarioId,
}: {
projectId: string,
scenarioId: string,
}) {
const router = useRouter();
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
async function fetchScenario() {
setError(null);
try {
const scenario = await getScenario(projectId, scenarioId);
setScenario(scenario);
} catch (error) {
setError(`Unable to fetch scenario: ${error}`);
} finally {
setLoading(false);
}
}
fetchScenario();
}, [scenarioId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await updateScenario(projectId, scenarioId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenarioId}`);
} catch (error) {
setError(`Unable to update scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Edit Scenario</h1>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
{!loading && scenario && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
defaultValue={scenario.name}
required
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
defaultValue={scenario.description}
required
/>
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}`}
>
Cancel
</Button>
</div>
</form>
)}
</div>;
}
function ViewScenario({
projectId,
scenarioId,
}: {
projectId: string,
scenarioId: string,
}) {
const router = useRouter();
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
async function fetchScenario() {
const scenario = await getScenario(projectId, scenarioId);
setScenario(scenario);
setLoading(false);
}
fetchScenario();
}, [scenarioId, projectId]);
async function handleDelete() {
try {
await deleteScenario(projectId, scenarioId);
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setDeleteError(`Failed to delete scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">View Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Scenarios
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !scenario && <div className="text-gray-600 text-center">Scenario not found</div>}
{!loading && scenario && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Name</div>
<div className="flex-[2]">{scenario.name}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Description</div>
<div className="flex-[2]">{scenario.description}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Created</div>
<div className="flex-[2]"><RelativeTime date={new Date(scenario.createdAt)} /></div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Last Updated</div>
<div className="flex-[2]"><RelativeTime date={new Date(scenario.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onClick={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this scenario?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
}
function NewScenario({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setError(null);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
try {
const scenario = await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenario._id}`);
} catch (error) {
setError(`Unable to create scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Scenarios
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
required
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
required
/>
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
}
function ScenarioList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
let ignore = false;
async function fetchScenarios() {
setLoading(true);
setError(null);
try {
const scenarios = await listScenarios(projectId, page, pageSize);
if (!ignore) {
setScenarios(scenarios.scenarios);
setTotal(Math.ceil(scenarios.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch scenarios: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchScenarios();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Scenarios</h1>
<Button
size="sm"
onClick={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Scenario
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-7 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Description</div>
<div className="col-span-1 px-4">Created</div>
<div className="col-span-1 px-4">Updated</div>
</div>
{/* Rows */}
{scenarios.map((scenario) => (
<div key={scenario._id} className="grid grid-cols-7 py-2 border-b hover:bg-gray-50 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/scenarios/${scenario._id}`}
className="text-blue-600 hover:underline"
>
{scenario.name}
</Link>
</div>
<div className="col-span-3 px-4 truncate">{scenario.description}</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(scenario.createdAt)} />
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(scenario.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/scenarios?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
}
export function ScenariosApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let scenarioId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
selection = "edit";
scenarioId = slug[0];
} else {
selection = "view";
scenarioId = slug[0];
}
}
return <>
{selection === "list" && <ScenarioList projectId={projectId} />}
{selection === "new" && <NewScenario projectId={projectId} />}
{selection === "view" && scenarioId && <ViewScenario projectId={projectId} scenarioId={scenarioId} />}
{selection === "edit" && scenarioId && <EditScenario projectId={projectId} scenarioId={scenarioId} />}
</>;
}

View file

@ -0,0 +1,690 @@
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile, TestScenario, TestSimulation } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createSimulation, getSimulation, listSimulations, updateSimulation, deleteSimulation, listScenarios, getScenario, getProfile } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon, ArrowLeftIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { RelativeTime } from "@primer/react"
import { ScenarioSelector } from "@/app/lib/components/selectors/scenario-selector";
import { ProfileSelector } from "@/app/lib/components/selectors/profile-selector";
function EditSimulation({
projectId,
simulationId,
}: {
projectId: string,
simulationId: string,
}) {
const router = useRouter();
const [simulation, setSimulation] = useState<WithStringId<z.infer<typeof TestSimulation>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [isScenarioModalOpen, setIsScenarioModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
useEffect(() => {
async function fetchSimulation() {
setError(null);
try {
const simulation = await getSimulation(projectId, simulationId);
setSimulation(simulation);
if (simulation) {
const [scenarioResult, profileResult] = await Promise.all([
getScenario(projectId, simulation.scenarioId),
getProfile(projectId, simulation.profileId),
]);
setScenario(scenarioResult);
setProfile(profileResult);
}
} catch (error) {
setError(`Unable to fetch simulation: ${error}`);
} finally {
setLoading(false);
}
}
fetchSimulation();
}, [simulationId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const passCriteria = formData.get("passCriteria") as string;
if (!name || !passCriteria) {
throw new Error("Name and Pass Criteria are required");
}
if (!scenario || !profile) {
throw new Error("Please select all required fields");
}
await updateSimulation(projectId, simulationId, {
name,
scenarioId: scenario._id,
profileId: profile._id,
passCriteria
});
router.push(`/projects/${projectId}/test/simulations/${simulationId}`);
} catch (error) {
setError(`Unable to update simulation: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Edit Simulation</h1>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
{!loading && simulation && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the simulation"
defaultValue={simulation.name}
required
/>
<Input
type="text"
name="passCriteria"
label="Pass Criteria"
placeholder="Enter the criteria for passing this simulation"
defaultValue={simulation.passCriteria}
required
/>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Scenario</label>
<div className="flex items-center gap-2">
{scenario ? (
<div className="text-sm text-blue-600">{scenario.name}</div>
) : (
<div className="text-sm text-gray-500">No scenario selected</div>
)}
<Button
size="sm"
onClick={() => setIsScenarioModalOpen(true)}
type="button"
>
{scenario ? "Change" : "Select"} Scenario
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Profile</label>
<div className="flex items-center gap-2">
{profile ? (
<div className="text-sm text-blue-600">{profile.name}</div>
) : (
<div className="text-sm text-gray-500">No profile selected</div>
)}
<Button
size="sm"
onClick={() => setIsProfileModalOpen(true)}
type="button"
>
{profile ? "Change" : "Select"} Profile
</Button>
</div>
</div>
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
isDisabled: !scenario || !profile,
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/simulations/${simulationId}`}
>
Cancel
</Button>
</div>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</form>
)}
</div>;
}
function ViewSimulation({
projectId,
simulationId,
}: {
projectId: string,
simulationId: string,
}) {
const router = useRouter();
const [simulation, setSimulation] = useState<WithStringId<z.infer<typeof TestSimulation>> | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
useEffect(() => {
async function fetchSimulation() {
const simulation = await getSimulation(projectId, simulationId);
setSimulation(simulation);
if (simulation) {
const [scenarioResult, profileResult] = await Promise.all([
getScenario(projectId, simulation.scenarioId),
getProfile(projectId, simulation.profileId),
]);
setScenario(scenarioResult);
setProfile(profileResult);
}
setLoading(false);
}
fetchSimulation();
}, [simulationId, projectId]);
async function handleDelete() {
try {
await deleteSimulation(projectId, simulationId);
router.push(`/projects/${projectId}/test/simulations`);
} catch (error) {
setDeleteError(`Failed to delete simulation: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">View Simulation</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/simulations`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Simulations
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !simulation && <div className="text-gray-600 text-center">Simulation not found</div>}
{!loading && simulation && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Name</div>
<div className="flex-[2]">{simulation.name}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Scenario</div>
<div className="flex-[2]">
{scenario ? (
<Link href={`/projects/${projectId}/test/scenarios/${scenario._id}`} className="text-blue-600 hover:underline">
{scenario.name}
</Link>
) : (
<div className="text-sm text-gray-500">No scenario selected</div>
)}
</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Profile</div>
<div className="flex-[2]">
{profile ? (
<Link href={`/projects/${projectId}/test/profiles/${profile._id}`} className="text-blue-600 hover:underline">
{profile.name}
</Link>
) : (
<div className="text-sm text-gray-500">No profile selected</div>
)}
</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Pass Criteria</div>
<div className="flex-[2]">{simulation.passCriteria}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Created</div>
<div className="flex-[2]"><RelativeTime date={new Date(simulation.createdAt)} /></div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Last Updated</div>
<div className="flex-[2]"><RelativeTime date={new Date(simulation.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/simulations/${simulationId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onClick={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this simulation?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
}
function NewSimulation({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [isScenarioModalOpen, setIsScenarioModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const passCriteria = formData.get("passCriteria") as string;
if (!name || !passCriteria) {
throw new Error("Name and Pass Criteria are required");
}
if (!scenario) {
throw new Error("Please select a scenario");
}
if (!profile) {
throw new Error("Please select a profile");
}
const result = await createSimulation(projectId, {
name,
scenarioId: scenario._id,
profileId: profile._id,
passCriteria,
});
router.push(`/projects/${projectId}/test/simulations/${result._id}`);
} catch (error) {
setError(`Unable to create simulation: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Simulation</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/simulations`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Simulations
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the simulation"
required
/>
<Input
type="text"
name="passCriteria"
label="Pass Criteria"
placeholder="Enter the criteria for passing this simulation"
required
/>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Scenario</label>
<div className="flex items-center gap-2">
{scenario ? (
<div className="text-sm text-blue-600">{scenario.name}</div>
) : (
<div className="text-sm text-gray-500">No scenario selected</div>
)}
<Button
size="sm"
onClick={() => setIsScenarioModalOpen(true)}
type="button"
>
{scenario ? "Change" : "Select"} Scenario
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Profile</label>
<div className="flex items-center gap-2">
{profile ? (
<div className="text-sm text-blue-600">{profile.name}</div>
) : (
<div className="text-sm text-gray-500">No profile selected</div>
)}
<Button
size="sm"
onClick={() => setIsProfileModalOpen(true)}
type="button"
>
{profile ? "Change" : "Select"} Profile
</Button>
</div>
</div>
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
isDisabled: !scenario || !profile,
}}
/>
</form>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</div>;
}
function SimulationList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [simulationList, setSimulationList] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [scenarioMap, setScenarioMap] = useState<Record<string, WithStringId<z.infer<typeof TestScenario>>>>({});
const [profileMap, setProfileMap] = useState<Record<string, WithStringId<z.infer<typeof TestProfile>>>>({});
const [total, setTotal] = useState(0);
useEffect(() => {
let ignore = false;
async function fetchSimulation() {
setLoading(true);
setError(null);
try {
const result = await listSimulations(projectId, page, pageSize);
if (!ignore) {
setSimulationList(result.simulations);
setTotal(Math.ceil(result.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch simulation: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchSimulation();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
useEffect(() => {
let ignore = false;
async function resolveScenarios() {
const scenarioIds = simulationList.reduce((acc, simulation) => {
if (!acc.includes(simulation.scenarioId)) {
acc.push(simulation.scenarioId);
}
return acc;
}, [] as string[]);
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => getScenario(projectId, scenarioId)));
if (ignore) {
return;
}
setScenarioMap(scenarios.filter((scenario) => scenario !== null).reduce((acc, scenario) => {
acc[scenario._id] = scenario;
return acc;
}, {} as Record<string, WithStringId<z.infer<typeof TestScenario>>>));
}
async function resolveProfiles() {
const profileIds = simulationList.reduce((acc, simulation) => {
if (!acc.includes(simulation.profileId)) {
acc.push(simulation.profileId);
}
return acc;
}, [] as string[]);
const profiles = await Promise.all(profileIds.map((profileId) => getProfile(projectId, profileId)));
if (ignore) {
return;
}
setProfileMap(profiles.filter((profile) => profile !== null).reduce((acc, profile) => {
acc[profile._id] = profile;
return acc;
}, {} as Record<string, WithStringId<z.infer<typeof TestProfile>>>));
}
if (error == null) {
resolveScenarios();
resolveProfiles();
}
return () => {
ignore = true;
};
}, [simulationList, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Simulations</h1>
<Button
size="sm"
onClick={() => router.push(`/projects/${projectId}/test/simulations/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Simulation
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onClick={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{simulationList.length === 0 && <div className="text-gray-600 text-center">No simulation found</div>}
{simulationList.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-9 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Scenario</div>
<div className="col-span-1 px-4">Profile</div>
<div className="col-span-1 px-4">Criteria</div>
<div className="col-span-1 px-4">Created</div>
<div className="col-span-1 px-4">Updated</div>
</div>
{/* Rows */}
{simulationList.map((simulation) => (
<div key={simulation._id} className="grid grid-cols-9 py-2 border-b hover:bg-gray-50 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/simulations/${simulation._id}`}
className="text-blue-600 hover:underline"
>
{simulation.name}
</Link>
</div>
<div className="col-span-3 px-4 truncate">
{scenarioMap[simulation.scenarioId]?.name || (
<span className="text-gray-500 font-mono text-xs">{simulation.scenarioId}</span>
)}
</div>
<div className="col-span-1 px-4 truncate">
{profileMap[simulation.profileId]?.name || (
<span className="text-gray-500 font-mono text-xs">{simulation.profileId}</span>
)}
</div>
<div className="col-span-1 px-4 truncate">
{simulation.passCriteria}
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(simulation.createdAt)} />
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(simulation.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/simulations?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
}
export function SimulationsApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let simulationId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
selection = "edit";
simulationId = slug[0];
} else {
selection = "view";
simulationId = slug[0];
}
}
return <>
{selection === "list" && <SimulationList projectId={projectId} />}
{selection === "new" && <NewSimulation projectId={projectId} />}
{selection === "view" && simulationId && <ViewSimulation projectId={projectId} simulationId={simulationId} />}
{selection === "edit" && simulationId && <EditSimulation projectId={projectId} simulationId={simulationId} />}
</>;
}

View file

@ -9,6 +9,8 @@ import { WorkflowSelector } from "./workflow_selector";
import { Spinner } from "@nextui-org/react";
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions";
import { listDataSources } from "../../../actions/datasource_actions";
import { TestProfile } from "@/app/lib/types/testing_types";
import { getDefaultProfile } from "../../../actions/testing_actions";
export function App({
projectId,
@ -19,6 +21,7 @@ export function App({
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
const [defaultTestProfile, setDefaultTestProfile] = useState<z.infer<typeof TestProfile> | null>(null);
const [loading, setLoading] = useState(false);
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
@ -27,11 +30,13 @@ export function App({
const workflow = await fetchWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
const defaultTestProfile = await getDefaultProfile(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setDefaultTestProfile(defaultTestProfile);
setLoading(false);
}, [projectId]);
@ -47,11 +52,13 @@ export function App({
const workflow = await createWorkflow(projectId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
const testProfile = await getDefaultProfile(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setDefaultTestProfile(testProfile);
setLoading(false);
}
@ -60,11 +67,13 @@ export function App({
const workflow = await cloneWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
const testProfile = await getDefaultProfile(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setDefaultTestProfile(testProfile);
setLoading(false);
}
@ -98,10 +107,11 @@ export function App({
handleCreateNewVersion={handleCreateNewVersion}
autoSelectIfOnlyOneWorkflow={autoSelectIfOnlyOneWorkflow}
/>}
{!loading && workflow && (dataSources !== null) && <WorkflowEditor
{!loading && workflow && (dataSources !== null) && (defaultTestProfile !== null) && <WorkflowEditor
key={workflow._id}
workflow={workflow}
dataSources={dataSources}
initialTestProfile={defaultTestProfile}
publishedWorkflowId={publishedWorkflowId}
handleShowSelector={handleShowSelector}
handleCloneVersion={handleCloneVersion}

View file

@ -29,6 +29,7 @@ import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/i
import { CopyIcon, Layers2Icon, RadioIcon, RedoIcon, Sparkles, UndoIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { TestProfile } from "@/app/lib/types/testing_types";
enablePatches();
@ -533,12 +534,14 @@ export function WorkflowEditor({
publishedWorkflowId,
handleShowSelector,
handleCloneVersion,
initialTestProfile,
}: {
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
handleShowSelector: () => void;
handleCloneVersion: (workflowId: string) => void;
initialTestProfile: z.infer<typeof TestProfile>;
}) {
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
patches: [],
@ -859,6 +862,7 @@ export function WorkflowEditor({
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
initialTestProfile={initialTestProfile}
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={state.present.selection.name}