mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-05 05:12:38 +02:00
simplify workflow version mgmt
This commit is contained in:
parent
1b19a9bcba
commit
23681d8b4d
49 changed files with 385 additions and 4767 deletions
|
|
@ -58,12 +58,13 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
|
|||
}
|
||||
|
||||
export async function getAssistantResponseStreamId(
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
messages: z.infer<typeof Message>[],
|
||||
): Promise<{ streamId: string } | { billingError: string }> {
|
||||
await projectAuthCheck(workflow.projectId);
|
||||
if (!await check_query_limit(workflow.projectId)) {
|
||||
await projectAuthCheck(projectId);
|
||||
if (!await check_query_limit(projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +83,6 @@ export async function getAssistantResponseStreamId(
|
|||
return { billingError: error || 'Billing error' };
|
||||
}
|
||||
|
||||
const response = await getAgenticResponseStreamId(workflow, projectTools, messages);
|
||||
const response = await getAgenticResponseStreamId(projectId, workflow, projectTools, messages);
|
||||
return response;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { z } from "zod";
|
||||
import { WorkflowTool } from "../lib/types/workflow_types";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
|
||||
import { projectsCollection } from "../lib/mongodb";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { MCPServer, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
|
||||
import { getMcpClient } from "../lib/mcp";
|
||||
|
|
@ -169,72 +169,6 @@ export async function updateMcpServers(projectId: string, mcpServers: z.infer<ty
|
|||
}, { $set: { mcpServers } });
|
||||
}
|
||||
|
||||
export async function listMcpServers(projectId: string): Promise<z.infer<typeof MCPServer>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
return project?.mcpServers ?? [];
|
||||
}
|
||||
|
||||
export async function updateToolInAllWorkflows(
|
||||
projectId: string,
|
||||
mcpServer: z.infer<typeof MCPServer>,
|
||||
toolId: string,
|
||||
shouldAdd: boolean
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// 1. Get all workflows in the project
|
||||
const workflows = await agentWorkflowsCollection.find({ projectId }).toArray();
|
||||
|
||||
// 2. For each workflow
|
||||
for (const workflow of workflows) {
|
||||
// 3. Find if the tool already exists in this workflow
|
||||
const existingTool = workflow.tools.find(t =>
|
||||
t.isMcp &&
|
||||
t.mcpServerName === mcpServer.name &&
|
||||
t.name === toolId
|
||||
);
|
||||
|
||||
if (shouldAdd && !existingTool) {
|
||||
// 4a. If adding and tool doesn't exist, add it
|
||||
const tool = mcpServer.tools.find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
const workflowTool = convertMcpServerToolToWorkflowTool(
|
||||
{
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: tool.parameters?.properties ?? {},
|
||||
required: tool.parameters?.required ?? [],
|
||||
},
|
||||
},
|
||||
mcpServer
|
||||
);
|
||||
workflow.tools.push(workflowTool);
|
||||
}
|
||||
} else if (!shouldAdd && existingTool) {
|
||||
// 4b. If removing and tool exists, remove it
|
||||
workflow.tools = workflow.tools.filter(t =>
|
||||
!(t.isMcp && t.mcpServerName === mcpServer.name && t.name === toolId)
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update the workflow
|
||||
await agentWorkflowsCollection.updateOne(
|
||||
{ _id: workflow._id },
|
||||
{
|
||||
$set: {
|
||||
tools: workflow.tools,
|
||||
lastUpdatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMcpTool(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use server';
|
||||
import { redirect } from "next/navigation";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, testScenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection, testProfilesCollection } from "../lib/mongodb";
|
||||
import { db, dataSourcesCollection, embeddingsCollection, projectsCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
|
@ -33,7 +33,11 @@ export async function projectAuthCheck(projectId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
|
||||
async function createBaseProject(
|
||||
name: string,
|
||||
user: WithStringId<z.infer<typeof User>>,
|
||||
workflow?: z.infer<typeof Workflow>
|
||||
): Promise<{ id: string } | { billingError: string }> {
|
||||
// fetch project count for this user
|
||||
const projectCount = await projectsCollection.countDocuments({
|
||||
createdByUserId: user._id,
|
||||
|
|
@ -60,9 +64,10 @@ async function createBaseProject(name: string, user: WithStringId<z.infer<typeof
|
|||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
createdByUserId: user._id,
|
||||
draftWorkflow: workflow,
|
||||
liveWorkflow: workflow,
|
||||
chatClientId,
|
||||
secret,
|
||||
nextWorkflowNumber: 1,
|
||||
testRunCounter: 0,
|
||||
});
|
||||
|
||||
|
|
@ -85,26 +90,19 @@ export async function createProject(formData: FormData): Promise<{ id: string }
|
|||
const name = formData.get('name') as string;
|
||||
const templateKey = formData.get('template') as string;
|
||||
|
||||
const response = await createBaseProject(name, user);
|
||||
const { agents, prompts, tools, startAgent } = templates[templateKey];
|
||||
const response = await createBaseProject(name, user, {
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
startAgent,
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
});
|
||||
if ('billingError' in response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const projectId = response.id;
|
||||
|
||||
// Add first workflow version with specified template
|
||||
const { agents, prompts, tools, startAgent } = templates[templateKey];
|
||||
await agentWorkflowsCollection.insertOne({
|
||||
projectId,
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
startAgent,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
name: `Version 1`,
|
||||
});
|
||||
|
||||
return { id: projectId };
|
||||
}
|
||||
|
||||
|
|
@ -270,13 +268,13 @@ export async function deleteProject(projectId: string) {
|
|||
projectId,
|
||||
});
|
||||
|
||||
// delete workflows
|
||||
await agentWorkflowsCollection.deleteMany({
|
||||
// delete workflow versions
|
||||
await db.collection('agent_workflows').deleteMany({
|
||||
projectId,
|
||||
});
|
||||
|
||||
// delete scenarios
|
||||
await testScenariosCollection.deleteMany({
|
||||
await db.collection('test_scenarios').deleteMany({
|
||||
projectId,
|
||||
});
|
||||
|
||||
|
|
@ -292,26 +290,19 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
|
|||
const user = await authCheck();
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
const response = await createBaseProject(name, user);
|
||||
const { agents, prompts, tools, startAgent } = templates['default'];
|
||||
const response = await createBaseProject(name, user, {
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
startAgent,
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
});
|
||||
if ('billingError' in response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const projectId = response.id;
|
||||
|
||||
// Add first workflow version with default template
|
||||
const { agents, prompts, tools, startAgent } = templates['default'];
|
||||
await agentWorkflowsCollection.insertOne({
|
||||
projectId,
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
startAgent,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
name: `Version 1`,
|
||||
});
|
||||
|
||||
return { id: projectId };
|
||||
}
|
||||
|
||||
|
|
@ -325,29 +316,69 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
|
|||
throw new Error('Invalid JSON');
|
||||
}
|
||||
// Validate and parse with zod
|
||||
const parsed = Workflow.omit({ projectId: true }).safeParse(workflowData);
|
||||
const parsed = Workflow.safeParse(workflowData);
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
|
||||
}
|
||||
const workflow = parsed.data;
|
||||
const name = workflow.name || 'Imported Project';
|
||||
const response = await createBaseProject(name, user);
|
||||
const name = 'Imported Project';
|
||||
const response = await createBaseProject(name, user, workflow);
|
||||
if ('billingError' in response) {
|
||||
return response;
|
||||
}
|
||||
const projectId = response.id;
|
||||
const now = new Date().toISOString();
|
||||
await agentWorkflowsCollection.insertOne({
|
||||
...workflow,
|
||||
projectId,
|
||||
createdAt: now,
|
||||
lastUpdatedAt: now,
|
||||
name: workflow.name || 'Version 1',
|
||||
});
|
||||
return { id: projectId };
|
||||
}
|
||||
|
||||
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
return libCollectProjectTools(projectId);
|
||||
}
|
||||
|
||||
export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// update the project's draft workflow
|
||||
workflow.lastUpdatedAt = new Date().toISOString();
|
||||
await projectsCollection.updateOne({
|
||||
_id: projectId,
|
||||
}, {
|
||||
$set: {
|
||||
draftWorkflow: workflow,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// update the project's draft workflow
|
||||
workflow.lastUpdatedAt = new Date().toISOString();
|
||||
await projectsCollection.updateOne({
|
||||
_id: projectId,
|
||||
}, {
|
||||
$set: {
|
||||
liveWorkflow: workflow,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revertToLiveWorkflow(projectId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const project = await getProjectConfig(projectId);
|
||||
const workflow = project.liveWorkflow;
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No live workflow found');
|
||||
}
|
||||
|
||||
workflow.lastUpdatedAt = new Date().toISOString();
|
||||
await projectsCollection.updateOne({
|
||||
_id: projectId,
|
||||
}, {
|
||||
$set: {
|
||||
draftWorkflow: workflow,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,610 +0,0 @@
|
|||
'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;
|
||||
description?: string;
|
||||
scenarioId: string;
|
||||
profileId: string | null;
|
||||
passCriteria: string;
|
||||
}
|
||||
): Promise<WithStringId<z.infer<typeof TestSimulation>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const doc: z.infer<typeof TestSimulation> = {
|
||||
...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;
|
||||
description?: string;
|
||||
scenarioId?: string;
|
||||
profileId?: string | null;
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
passCount: 0,
|
||||
failCount: 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;
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const updateData: any = {
|
||||
...updates,
|
||||
};
|
||||
|
||||
await testRunsCollection.updateOne(
|
||||
{
|
||||
_id: new ObjectId(runId),
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
$set: updateData,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelRun(projectId: string, runId: string): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await testRunsCollection.updateOne(
|
||||
{ _id: new ObjectId(runId), projectId },
|
||||
{ $set: { status: 'cancelled' } }
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
transcript: 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSimulationResult(
|
||||
projectId: string,
|
||||
runId: string,
|
||||
simulationId: string
|
||||
): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const result = await testResultsCollection.findOne({
|
||||
projectId,
|
||||
runId,
|
||||
simulationId
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
return {
|
||||
...rest,
|
||||
_id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRunSimulations(
|
||||
projectId: string,
|
||||
simulationIds: string[]
|
||||
): Promise<WithStringId<z.infer<typeof TestSimulation>>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const simulations = await testSimulationsCollection
|
||||
.find({
|
||||
_id: { $in: simulationIds.map(id => new ObjectId(id)) },
|
||||
projectId
|
||||
})
|
||||
.toArray();
|
||||
|
||||
// Fetch associated scenario and profile names
|
||||
const enrichedSimulations = await Promise.all(simulations.map(async (simulation) => {
|
||||
const scenario = simulation.scenarioId ? await testScenariosCollection.findOne({ _id: new ObjectId(simulation.scenarioId) }) : null;
|
||||
const profile = simulation.profileId ? await testProfilesCollection.findOne({ _id: new ObjectId(simulation.profileId) }) : null;
|
||||
return {
|
||||
...simulation,
|
||||
_id: simulation._id.toString(),
|
||||
scenarioName: scenario?.name || 'Unknown',
|
||||
profileName: profile?.name || 'None',
|
||||
};
|
||||
}));
|
||||
|
||||
return enrichedSimulations;
|
||||
}
|
||||
|
|
@ -90,13 +90,12 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
|
|||
found: existingConfig
|
||||
});
|
||||
|
||||
const configToSave = {
|
||||
const configToSave: z.infer<typeof TwilioConfig> = {
|
||||
phone_number: params.phone_number,
|
||||
account_sid: params.account_sid,
|
||||
auth_token: params.auth_token,
|
||||
label: params.label || '', // Use empty string instead of undefined
|
||||
project_id: params.project_id,
|
||||
workflow_id: params.workflow_id,
|
||||
createdAt: existingConfig?.createdAt || new Date(),
|
||||
status: 'active' as const
|
||||
};
|
||||
|
|
@ -108,7 +107,6 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
|
|||
params.phone_number,
|
||||
params.account_sid,
|
||||
params.auth_token,
|
||||
params.workflow_id
|
||||
);
|
||||
|
||||
// Then save/update the config in database
|
||||
|
|
@ -190,7 +188,6 @@ async function configureInboundCall(
|
|||
phone_number: string,
|
||||
account_sid: string,
|
||||
auth_token: string,
|
||||
workflow_id: string
|
||||
): Promise<InboundConfigResponse> {
|
||||
try {
|
||||
// Normalize phone number format
|
||||
|
|
@ -200,7 +197,6 @@ async function configureInboundCall(
|
|||
|
||||
console.log('Configuring inbound call for:', {
|
||||
phone_number,
|
||||
workflow_id
|
||||
});
|
||||
|
||||
// Initialize Twilio client
|
||||
|
|
@ -262,7 +258,6 @@ async function configureInboundCall(
|
|||
return {
|
||||
status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
|
||||
phone_number: phone_number,
|
||||
workflow_id: workflow_id,
|
||||
previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
'use server';
|
||||
import { ObjectId, WithId } from "mongodb";
|
||||
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import { templates } from "../lib/project_templates";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { WithStringId } from "../lib/types/types";
|
||||
import { Workflow } from "../lib/types/workflow_types";
|
||||
|
||||
export async function createWorkflow(projectId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// get the next workflow number
|
||||
const doc = await projectsCollection.findOneAndUpdate({
|
||||
_id: projectId,
|
||||
}, {
|
||||
$inc: {
|
||||
nextWorkflowNumber: 1,
|
||||
},
|
||||
}, {
|
||||
returnDocument: 'after'
|
||||
});
|
||||
if (!doc) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const nextWorkflowNumber = doc.nextWorkflowNumber;
|
||||
|
||||
// create the workflow
|
||||
const { agents, prompts, tools, startAgent } = templates['default'];
|
||||
const workflow = {
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
startAgent,
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
name: `Version ${nextWorkflowNumber}`,
|
||||
};
|
||||
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
|
||||
const { _id, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
|
||||
return {
|
||||
...rest,
|
||||
_id: insertedId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function cloneWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// create a new workflow with the same content
|
||||
const newWorkflow = {
|
||||
...workflow,
|
||||
_id: new ObjectId(),
|
||||
name: `Copy of ${workflow.name || 'Unnamed workflow'}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { insertedId } = await agentWorkflowsCollection.insertOne(newWorkflow);
|
||||
const { _id, ...rest } = newWorkflow as WithId<z.infer<typeof Workflow>>;
|
||||
return {
|
||||
...rest,
|
||||
_id: insertedId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameWorkflow(projectId: string, workflowId: string, name: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await agentWorkflowsCollection.updateOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
}, {
|
||||
$set: {
|
||||
name,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveWorkflow(projectId: string, workflowId: string, workflow: z.infer<typeof Workflow>) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// check if workflow exists
|
||||
const existingWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!existingWorkflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// ensure that this is not the published workflow for this project
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
if (publishedWorkflowId && publishedWorkflowId === workflowId) {
|
||||
throw new Error('Cannot save published workflow');
|
||||
}
|
||||
|
||||
// update the workflow, except name and description
|
||||
const { _id, name, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
|
||||
await agentWorkflowsCollection.updateOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
}, {
|
||||
$set: {
|
||||
...rest,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishWorkflow(projectId: string, workflowId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// check if workflow exists
|
||||
const existingWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!existingWorkflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
// publish the workflow
|
||||
await projectsCollection.updateOne({
|
||||
"_id": projectId,
|
||||
}, {
|
||||
$set: {
|
||||
publishedWorkflowId: workflowId,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPublishedWorkflowId(projectId: string): Promise<string | null> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
return project?.publishedWorkflowId || null;
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(workflowId),
|
||||
projectId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
const { _id, ...rest } = workflow;
|
||||
return {
|
||||
...rest,
|
||||
_id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listWorkflows(
|
||||
projectId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{
|
||||
workflows: (WithStringId<z.infer<typeof Workflow>>)[];
|
||||
total: number;
|
||||
publishedWorkflowId: string | null;
|
||||
}> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// fetch total count
|
||||
const total = await agentWorkflowsCollection.countDocuments({ projectId });
|
||||
|
||||
// fetch published workflow
|
||||
let publishedWorkflowId: string | null = null;
|
||||
let publishedWorkflow: WithId<z.infer<typeof Workflow>> | null = null;
|
||||
if (page === 1) {
|
||||
publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
if (publishedWorkflowId) {
|
||||
publishedWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(publishedWorkflowId),
|
||||
projectId,
|
||||
}, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
description: 1,
|
||||
createdAt: 1,
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// fetch workflows with pagination
|
||||
let workflows: WithId<z.infer<typeof Workflow>>[] = await agentWorkflowsCollection.find(
|
||||
{
|
||||
projectId,
|
||||
...(publishedWorkflowId ? {
|
||||
_id: {
|
||||
$ne: new ObjectId(publishedWorkflowId)
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
{
|
||||
sort: { lastUpdatedAt: -1 },
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
description: 1,
|
||||
createdAt: 1,
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
skip: (page - 1) * limit,
|
||||
limit: limit,
|
||||
}
|
||||
).toArray();
|
||||
workflows = [
|
||||
...(publishedWorkflow ? [publishedWorkflow] : []),
|
||||
...workflows,
|
||||
];
|
||||
|
||||
// return workflows
|
||||
return {
|
||||
workflows: workflows.map((w) => {
|
||||
const { _id, ...rest } = w;
|
||||
return {
|
||||
...rest,
|
||||
_id: _id.toString(),
|
||||
};
|
||||
}),
|
||||
total,
|
||||
publishedWorkflowId,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
import { redisClient } from "@/app/lib/redis";
|
||||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { streamResponse } from "@/app/lib/agents";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const PayloadSchema = z.object({
|
||||
workflow: Workflow,
|
||||
projectTools: z.array(WorkflowTool),
|
||||
messages: z.array(Message),
|
||||
});
|
||||
import { ZStreamAgentResponsePayload } from "@/app/lib/types/types";
|
||||
|
||||
export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
|
||||
const params = await props.params;
|
||||
|
|
@ -21,13 +13,13 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
|
|||
}
|
||||
|
||||
// parse the payload
|
||||
const { workflow, projectTools, messages } = PayloadSchema.parse(JSON.parse(payload));
|
||||
const { projectId, workflow, projectTools, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload));
|
||||
console.log('payload', payload);
|
||||
|
||||
// fetch billing customer id
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
billingCustomerId = await getCustomerIdForProject(workflow.projectId);
|
||||
billingCustomerId = await getCustomerIdForProject(projectId);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -37,7 +29,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
|
|||
async start(controller) {
|
||||
try {
|
||||
// Iterate over the generator
|
||||
for await (const event of streamResponse(workflow, projectTools, messages)) {
|
||||
for await (const event of streamResponse(projectId, workflow, projectTools, messages)) {
|
||||
// Check if this is a message event (has role property)
|
||||
if ('role' in event) {
|
||||
if (event.role === 'assistant') {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { getResponse } from "@/app/lib/agents";
|
||||
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { projectsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { collectProjectTools } from "@/app/lib/project_tools";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { z } from "zod";
|
||||
import { TwilioInboundCall } from "@/app/lib/types/voice_types";
|
||||
import { hangup, reject, XmlResponse, ZStandardRequestParams } from "../utils";
|
||||
|
|
@ -63,16 +62,19 @@ export async function POST(request: Request) {
|
|||
return reject('rejected');
|
||||
}
|
||||
|
||||
// extract workflow and project id and fetch workflow from db
|
||||
// fetch project and extract live workflow
|
||||
// if workflow not found, reject the call
|
||||
const projectId = twilioConfig.project_id;
|
||||
const workflowId = twilioConfig.workflow_id;
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: projectId,
|
||||
_id: new ObjectId(workflowId),
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
if (!project) {
|
||||
logger.log(`Project ${projectId} not found`);
|
||||
return reject('rejected');
|
||||
}
|
||||
const workflow = project.liveWorkflow;
|
||||
if (!workflow) {
|
||||
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
|
||||
logger.log(`Workflow not found for project ${projectId}`);
|
||||
return reject('rejected');
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ export async function POST(request: Request) {
|
|||
|
||||
// this is the first turn, get the initial assistant response
|
||||
// and validate it
|
||||
const { messages } = await getResponse(workflow, projectTools, []);
|
||||
const { messages } = await getResponse(projectId, workflow, projectTools, []);
|
||||
if (messages.length === 0) {
|
||||
logger.log('Agent response is empty');
|
||||
return hangup();
|
||||
|
|
@ -98,7 +100,6 @@ export async function POST(request: Request) {
|
|||
to: data.To,
|
||||
from: data.From,
|
||||
projectId,
|
||||
workflowId,
|
||||
messages,
|
||||
createdAt: recvdAt.toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { getResponse } from "@/app/lib/agents";
|
||||
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { projectsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { collectProjectTools } from "@/app/lib/project_tools";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { z } from "zod";
|
||||
import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
|
|
@ -35,15 +34,19 @@ export async function POST(
|
|||
logger.log('Call not found');
|
||||
return hangup();
|
||||
}
|
||||
const { workflowId, projectId } = call;
|
||||
const { projectId } = call;
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: projectId,
|
||||
_id: new ObjectId(workflowId),
|
||||
// fetch project and extract live workflow
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
if (!project) {
|
||||
logger.log(`Project ${projectId} not found`);
|
||||
return hangup();
|
||||
}
|
||||
const workflow = project.liveWorkflow;
|
||||
if (!workflow) {
|
||||
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
|
||||
logger.log(`Workflow not found for project ${projectId}`);
|
||||
return hangup();
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +61,7 @@ export async function POST(
|
|||
content: data.SpeechResult,
|
||||
}
|
||||
];
|
||||
const { messages } = await getResponse(workflow, projectTools, reqMessages);
|
||||
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages);
|
||||
if (messages.length === 0) {
|
||||
logger.log('Agent response is empty');
|
||||
return hangup();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { agentWorkflowsCollection, db, projectsCollection, testProfilesCollection } from "../../../../lib/mongodb";
|
||||
import { projectsCollection } from "../../../../lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { authCheck } from "../../utils";
|
||||
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
|
||||
import { check_query_limit } from "../../../../lib/rate_limiting";
|
||||
import { PrefixLogger } from "../../../../lib/utils";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { collectProjectTools } from "@/app/lib/project_tools";
|
||||
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
|
|
@ -65,19 +64,10 @@ export async function POST(
|
|||
// fetch project tools
|
||||
const projectTools = await collectProjectTools(projectId);
|
||||
|
||||
// if workflow id is provided in the request, use it, else use the published workflow id
|
||||
let workflowId = result.data.workflowId ?? project.publishedWorkflowId;
|
||||
if (!workflowId) {
|
||||
logger.log(`No workflow id provided in request or project has no published workflow`);
|
||||
return Response.json({ error: "No workflow id provided in request or project has no published workflow" }, { status: 404 });
|
||||
}
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: projectId,
|
||||
_id: new ObjectId(workflowId),
|
||||
});
|
||||
const workflow = project.liveWorkflow;
|
||||
if (!workflow) {
|
||||
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
|
||||
logger.log(`Workflow not found for project ${projectId}`);
|
||||
return Response.json({ error: "Workflow not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
|
@ -103,21 +93,8 @@ export async function POST(
|
|||
}
|
||||
}
|
||||
|
||||
// if test profile is provided in the request, use it
|
||||
let testProfile: z.infer<typeof TestProfile> | null = null;
|
||||
if (result.data.testProfileId) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// get assistant response
|
||||
const { messages } = await getResponse(workflow, projectTools, reqMessages);
|
||||
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages);
|
||||
|
||||
// log billing usage
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { agentWorkflowsCollection, projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
|
||||
import { projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
|
||||
import { z } from "zod";
|
||||
import { ObjectId, WithId } from "mongodb";
|
||||
import { authCheck } from "../../../utils";
|
||||
|
|
@ -185,10 +185,7 @@ export async function POST(
|
|||
const projectTools = await collectProjectTools(session.projectId);
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: session.projectId,
|
||||
_id: new ObjectId(projectSettings.publishedWorkflowId),
|
||||
});
|
||||
const workflow = projectSettings.liveWorkflow;
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found");
|
||||
}
|
||||
|
|
@ -214,7 +211,7 @@ export async function POST(
|
|||
const inMessages: z.infer<typeof Message>[] = convert(messages);
|
||||
inMessages.push(userMessage);
|
||||
|
||||
const { messages: responseMessages } = await getResponse(workflow, projectTools, [systemMessage, ...inMessages]);
|
||||
const { messages: responseMessages } = await getResponse(session.projectId, workflow, projectTools, [systemMessage, ...inMessages]);
|
||||
const convertedResponseMessages = convertBack(responseMessages);
|
||||
const unsavedMessages = [
|
||||
userMessage,
|
||||
|
|
|
|||
|
|
@ -489,6 +489,7 @@ function createComposioTool(
|
|||
// Helper to create an agent
|
||||
function createAgent(
|
||||
logger: PrefixLogger,
|
||||
projectId: string,
|
||||
config: z.infer<typeof WorkflowAgent>,
|
||||
tools: Record<string, Tool>,
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
|
|
@ -539,7 +540,7 @@ ${CHILD_TRANSFER_RELATED_INSTRUCTIONS}
|
|||
|
||||
// Add RAG tool if needed
|
||||
if (config.ragDataSources?.length) {
|
||||
const ragTool = createRagTool(logger, config, workflow.projectId);
|
||||
const ragTool = createRagTool(logger, config, projectId);
|
||||
agentTools.push(ragTool);
|
||||
|
||||
// update instructions to include RAG instructions
|
||||
|
|
@ -794,7 +795,12 @@ async function* emitGreetingTurn(logger: PrefixLogger, workflow: z.infer<typeof
|
|||
yield* emitEvent(logger, new UsageTracker().asEvent());
|
||||
}
|
||||
|
||||
function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, toolConfig: Record<string, z.infer<typeof WorkflowTool>>): Record<string, Tool> {
|
||||
function createTools(
|
||||
logger: PrefixLogger,
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
toolConfig: Record<string, z.infer<typeof WorkflowTool>>,
|
||||
): Record<string, Tool> {
|
||||
const tools: Record<string, Tool> = {};
|
||||
for (const [toolName, config] of Object.entries(toolConfig)) {
|
||||
if (workflow.mockTools?.[toolName]) {
|
||||
|
|
@ -804,16 +810,16 @@ function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, t
|
|||
});
|
||||
logger.log(`created mock tool: ${toolName}`);
|
||||
} else if (config.isMcp) {
|
||||
tools[toolName] = createMcpTool(logger, config, workflow.projectId);
|
||||
tools[toolName] = createMcpTool(logger, config, projectId);
|
||||
logger.log(`created mcp tool: ${toolName}`);
|
||||
} else if (config.isComposio) {
|
||||
tools[toolName] = createComposioTool(logger, config, workflow.projectId);
|
||||
tools[toolName] = createComposioTool(logger, config, projectId);
|
||||
logger.log(`created composio tool: ${toolName}`);
|
||||
} else if (config.mockTool) {
|
||||
tools[toolName] = createMockTool(logger, config);
|
||||
logger.log(`created mock tool: ${toolName}`);
|
||||
} else {
|
||||
tools[toolName] = createWebhookTool(logger, config, workflow.projectId);
|
||||
tools[toolName] = createWebhookTool(logger, config, projectId);
|
||||
logger.log(`created webhook tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -822,6 +828,7 @@ function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, t
|
|||
|
||||
function createAgents(
|
||||
logger: PrefixLogger,
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>,
|
||||
tools: Record<string, Tool>,
|
||||
|
|
@ -837,6 +844,7 @@ function createAgents(
|
|||
for (const [agentName, config] of Object.entries(agentConfig)) {
|
||||
const { agent, entities } = createAgent(
|
||||
logger,
|
||||
projectId,
|
||||
config,
|
||||
tools,
|
||||
projectTools,
|
||||
|
|
@ -918,6 +926,7 @@ function maybeInjectGiveUpControlInstructions(
|
|||
// Main function to stream an agentic response
|
||||
// using OpenAI Agents SDK
|
||||
export async function* streamResponse(
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
messages: z.infer<typeof Message>[],
|
||||
|
|
@ -926,8 +935,7 @@ export async function* streamResponse(
|
|||
console.log('-------------------- AGENT LOOP START --------------------');
|
||||
// set up logging
|
||||
let logger = new PrefixLogger(`agent-loop`)
|
||||
logger.log('projectId', workflow.projectId);
|
||||
logger.log('workflow', workflow.name);
|
||||
logger.log('projectId', projectId);
|
||||
|
||||
// ensure valid system message
|
||||
ensureSystemMessage(logger, messages);
|
||||
|
|
@ -946,10 +954,10 @@ export async function* streamResponse(
|
|||
logger.log(`initialized stack: ${JSON.stringify(stack)}`);
|
||||
|
||||
// create tools
|
||||
const tools = createTools(logger, workflow, toolConfig);
|
||||
const tools = createTools(logger, projectId, workflow, toolConfig);
|
||||
|
||||
// create agents
|
||||
const { agents, originalInstructions, originalHandoffs } = createAgents(logger, workflow, agentConfig, tools, projectTools, promptConfig);
|
||||
const { agents, originalInstructions, originalHandoffs } = createAgents(logger, projectId, workflow, agentConfig, tools, projectTools, promptConfig);
|
||||
|
||||
// track agent to agent calls
|
||||
const transferCounter = new AgentTransferCounter();
|
||||
|
|
@ -1203,6 +1211,7 @@ export async function* streamResponse(
|
|||
|
||||
// this is a sync version of streamResponse
|
||||
export async function getResponse(
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
messages: z.infer<typeof Message>[],
|
||||
|
|
@ -1218,7 +1227,7 @@ export async function getResponse(
|
|||
completion: 0,
|
||||
},
|
||||
};
|
||||
for await (const event of streamResponse(workflow, projectTools, messages)) {
|
||||
for await (const event of streamResponse(projectId, workflow, projectTools, messages)) {
|
||||
if ('role' in event) {
|
||||
out.push(event);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export const USE_KLAVIS_TOOLS = process.env.USE_KLAVIS_TOOLS === 'true';
|
|||
|
||||
// Hardcoded flags
|
||||
export const USE_MULTIPLE_PROJECTS = true;
|
||||
export const USE_TESTING_FEATURE = false;
|
||||
export const USE_VOICE_FEATURE = false;
|
||||
export const USE_TRANSFER_CONTROL_OPTIONS = true;
|
||||
export const USE_PRODUCT_TOUR = true;
|
||||
|
|
|
|||
84
apps/rowboat/app/lib/migrate_versioned_workflows.ts
Normal file
84
apps/rowboat/app/lib/migrate_versioned_workflows.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { agentWorkflowsCollection, projectsCollection } from "./mongodb";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { Workflow } from "./types/workflow_types";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function migrate_versioned_workflows(projectId: string) {
|
||||
// fetch project data
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectId} not found`);
|
||||
}
|
||||
|
||||
// Skip if project already has workflows migrated
|
||||
if (project.draftWorkflow && project.liveWorkflow) {
|
||||
console.log(`Project ${projectId} already has migrated workflows, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFields: { draftWorkflow?: z.infer<typeof Workflow>; liveWorkflow?: z.infer<typeof Workflow> } = {};
|
||||
|
||||
// 1. Migrate published workflow to liveWorkflow
|
||||
if (project.publishedWorkflowId) {
|
||||
const publishedWorkflow = await agentWorkflowsCollection.findOne({
|
||||
_id: new ObjectId(project.publishedWorkflowId)
|
||||
});
|
||||
|
||||
if (publishedWorkflow) {
|
||||
// @ts-ignore - Workflow type mismatch
|
||||
const { _id, name, createdAt, projectId, ...rest } = publishedWorkflow;
|
||||
updateFields.liveWorkflow = rest;
|
||||
console.log(`Found published workflow ${project.publishedWorkflowId} for project ${projectId}`);
|
||||
} else {
|
||||
console.warn(`Published workflow ${project.publishedWorkflowId} not found for project ${projectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find the latest workflow for draft (that isn't the published one)
|
||||
const workflows = await agentWorkflowsCollection.find({
|
||||
projectId,
|
||||
}).sort({ lastUpdatedAt: -1 }).toArray();
|
||||
|
||||
let latestWorkflow;
|
||||
for (const workflow of workflows) {
|
||||
// Skip if this is the published workflow
|
||||
if (project.publishedWorkflowId && workflow._id.toString() === project.publishedWorkflowId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
latestWorkflow = workflow;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle cases where no published workflow exists
|
||||
if (!updateFields.liveWorkflow && latestWorkflow) {
|
||||
// No published workflow found, use latest as live workflow
|
||||
// @ts-ignore - Workflow type mismatch
|
||||
const { _id, name, createdAt, projectId, ...rest } = latestWorkflow;
|
||||
updateFields.liveWorkflow = rest;
|
||||
console.log(`No published workflow found, using latest workflow as live for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Set draft workflow
|
||||
if (latestWorkflow) {
|
||||
// @ts-ignore - Workflow type mismatch
|
||||
const { _id, name, createdAt, projectId, ...rest } = latestWorkflow;
|
||||
updateFields.draftWorkflow = rest;
|
||||
console.log(`Found draft workflow for project ${projectId}`);
|
||||
} else if (updateFields.liveWorkflow) {
|
||||
// No separate draft found, use the published workflow as draft too
|
||||
updateFields.draftWorkflow = updateFields.liveWorkflow;
|
||||
console.log(`No separate draft found, using live workflow as draft for project ${projectId}`);
|
||||
}
|
||||
|
||||
// 3. Update the project with the migrated workflows
|
||||
if (Object.keys(updateFields).length > 0) {
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
console.log(`Successfully migrated ${Object.keys(updateFields).length} workflow(s) for project ${projectId}`);
|
||||
} else {
|
||||
console.log(`No workflows found to migrate for project ${projectId}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ 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 { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
|
||||
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
|
||||
import { z } from 'zod';
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
|
@ -23,11 +22,6 @@ export const projectMembersCollection = db.collection<z.infer<typeof ProjectMemb
|
|||
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
|
||||
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
|
||||
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
|
||||
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");
|
||||
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
||||
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { MCPServer } from "./types";
|
||||
import { WorkflowTool } from "./workflow_types";
|
||||
import { Workflow, WorkflowTool } from "./workflow_types";
|
||||
import { ZTool } from "../composio/composio";
|
||||
|
||||
export const ComposioConnectedAccount = z.object({
|
||||
|
|
@ -23,9 +23,10 @@ export const Project = z.object({
|
|||
createdByUserId: z.string(),
|
||||
secret: z.string(),
|
||||
chatClientId: z.string(),
|
||||
draftWorkflow: Workflow.optional(),
|
||||
liveWorkflow: Workflow.optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
publishedWorkflowId: z.string().optional(),
|
||||
nextWorkflowNumber: z.number().optional(),
|
||||
testRunCounter: z.number().default(0),
|
||||
mcpServers: z.array(MCPServer).optional(),
|
||||
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
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"),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const TestProfile = z.object({
|
||||
projectId: 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(),
|
||||
description: z.string().optional().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
scenarioId: z.string(),
|
||||
profileId: z.string().nullable(),
|
||||
passCriteria: z.string(),
|
||||
});
|
||||
|
||||
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(),
|
||||
completedAt: z.string().optional(),
|
||||
aggregateResults: z.object({
|
||||
total: z.number(),
|
||||
passCount: z.number(),
|
||||
failCount: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const TestResult = z.object({
|
||||
projectId: z.string(),
|
||||
runId: z.string(),
|
||||
simulationId: z.string(),
|
||||
result: z.union([z.literal('pass'), z.literal('fail')]),
|
||||
details: z.string(),
|
||||
transcript: z.string()
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { WorkflowTool } from "./workflow_types";
|
||||
import { Workflow, WorkflowTool } from "./workflow_types";
|
||||
|
||||
export const SystemMessage = z.object({
|
||||
role: z.literal("system"),
|
||||
|
|
@ -158,7 +158,6 @@ export type WithStringId<T> = T & { _id: string };
|
|||
export const ApiRequest = z.object({
|
||||
messages: z.array(Message),
|
||||
state: z.unknown(),
|
||||
workflowId: z.string().nullable().optional(),
|
||||
testProfileId: z.string().nullable().optional(),
|
||||
mockTools: z.record(z.string(), z.string()).nullable().optional(),
|
||||
});
|
||||
|
|
@ -208,3 +207,9 @@ export function convertMcpServerToolToWorkflowTool(
|
|||
|
||||
return converted;
|
||||
}
|
||||
export const ZStreamAgentResponsePayload = z.object({
|
||||
projectId: z.string(),
|
||||
workflow: Workflow,
|
||||
projectTools: z.array(WorkflowTool),
|
||||
messages: z.array(Message),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export const TwilioConfigParams = z.object({
|
|||
auth_token: z.string(),
|
||||
label: z.string(),
|
||||
project_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
});
|
||||
|
||||
export const TwilioConfig = TwilioConfigParams.extend({
|
||||
|
|
@ -24,7 +23,6 @@ export interface TwilioConfigResponse {
|
|||
export interface InboundConfigResponse {
|
||||
status: 'configured' | 'reconfigured';
|
||||
phone_number: string;
|
||||
workflow_id: string;
|
||||
previous_webhook?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -34,7 +32,6 @@ export const TwilioInboundCall = z.object({
|
|||
to: z.string(),
|
||||
from: z.string(),
|
||||
projectId: z.string(),
|
||||
workflowId: z.string(),
|
||||
messages: z.array(Message),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime().optional(),
|
||||
|
|
|
|||
|
|
@ -61,21 +61,16 @@ export const WorkflowTool = z.object({
|
|||
}).optional(), // the data for the Composio tool, if it is a Composio tool
|
||||
});
|
||||
export const Workflow = z.object({
|
||||
name: z.string().optional(),
|
||||
agents: z.array(WorkflowAgent),
|
||||
prompts: z.array(WorkflowPrompt),
|
||||
tools: z.array(WorkflowTool),
|
||||
startAgent: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime(),
|
||||
projectId: z.string(),
|
||||
mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions
|
||||
});
|
||||
export const WorkflowTemplate = Workflow
|
||||
.omit({
|
||||
projectId: true,
|
||||
lastUpdatedAt: true,
|
||||
createdAt: true,
|
||||
})
|
||||
.extend({
|
||||
name: z.string(),
|
||||
|
|
|
|||
|
|
@ -3,27 +3,30 @@ import { generateObject } from "ai";
|
|||
import { openai } from "@ai-sdk/openai";
|
||||
import { redisClient } from "./redis";
|
||||
import { Workflow, WorkflowTool } from "./types/workflow_types";
|
||||
import { Message } from "./types/types";
|
||||
import { Message, ZStreamAgentResponsePayload } from "./types/types";
|
||||
|
||||
export async function getAgenticResponseStreamId(
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
messages: z.infer<typeof Message>[],
|
||||
): Promise<{
|
||||
streamId: string,
|
||||
}> {
|
||||
// serialize the request
|
||||
const payload = JSON.stringify({
|
||||
const payload: z.infer<typeof ZStreamAgentResponsePayload> = {
|
||||
projectId,
|
||||
workflow,
|
||||
projectTools,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
// serialize the request
|
||||
const serialized = JSON.stringify(payload);
|
||||
|
||||
// create a uuid for the stream
|
||||
const streamId = crypto.randomUUID();
|
||||
|
||||
// store payload in redis
|
||||
await redisClient.set(`chat-stream-${streamId}`, payload, 'EX', 60 * 10); // expire in 10 minutes
|
||||
await redisClient.set(`chat-stream-${streamId}`, serialized, 'EX', 60 * 10); // expire in 10 minutes
|
||||
|
||||
return {
|
||||
streamId,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { configureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../../actions/voice_actions";
|
||||
import { TwilioConfig } from "../../../../lib/types/voice_types";
|
||||
import { TwilioConfig, TwilioConfigParams } from "../../../../lib/types/voice_types";
|
||||
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
|
||||
import { Section } from './project';
|
||||
import { clsx } from 'clsx';
|
||||
|
|
@ -198,23 +198,15 @@ export function VoiceSection({ projectId }: { projectId: string }) {
|
|||
return;
|
||||
}
|
||||
|
||||
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
|
||||
if (!workflowId) {
|
||||
setError('No workflow selected. Please select a workflow first.');
|
||||
setConfigurationValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const configParams = {
|
||||
const configParams: z.infer<typeof TwilioConfigParams> = {
|
||||
phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''),
|
||||
account_sid: formState.accountSid,
|
||||
auth_token: formState.authToken,
|
||||
label: formState.label,
|
||||
project_id: projectId,
|
||||
workflow_id: workflowId,
|
||||
};
|
||||
|
||||
const result = await configureTwilioNumber(configParams);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@ import { Chat } from "./components/chat";
|
|||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
|
||||
import { CheckIcon, CopyIcon, PlusIcon, UserIcon, InfoIcon, BugIcon, BugOffIcon, CodeIcon } from "lucide-react";
|
||||
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
const defaultSystemMessage = '';
|
||||
|
|
@ -40,7 +37,6 @@ export function App({
|
|||
triggerCopilotChat?: (message: string) => void;
|
||||
}) {
|
||||
const [counter, setCounter] = useState<number>(0);
|
||||
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
|
||||
const [systemMessage, setSystemMessage] = useState<string>(defaultSystemMessage);
|
||||
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
|
||||
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
|
||||
|
|
@ -59,11 +55,6 @@ export function App({
|
|||
setCounter(counter + 1);
|
||||
}
|
||||
|
||||
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>> | null) {
|
||||
setTestProfile(profile);
|
||||
setCounter(counter + 1);
|
||||
}
|
||||
|
||||
function handleNewChatButtonClick() {
|
||||
setCounter(counter + 1);
|
||||
setChat({
|
||||
|
|
@ -138,17 +129,6 @@ export function App({
|
|||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
{USE_TESTING_FEATURE && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsProfileSelectorOpen(true)}
|
||||
showHoverContent={true}
|
||||
hoverContent={testProfile?.name || 'Select test profile'}
|
||||
>
|
||||
<UserIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -166,22 +146,13 @@ export function App({
|
|||
}
|
||||
onClick={onPanelClick}
|
||||
>
|
||||
<ProfileSelector
|
||||
projectId={projectId}
|
||||
isOpen={isProfileSelectorOpen}
|
||||
onOpenChange={setIsProfileSelectorOpen}
|
||||
onSelect={handleTestProfileChange}
|
||||
selectedProfileId={testProfile?._id}
|
||||
/>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<Chat
|
||||
key={`chat-${counter}`}
|
||||
chat={chat}
|
||||
projectId={projectId}
|
||||
workflow={workflow}
|
||||
testProfile={testProfile}
|
||||
messageSubscriber={messageSubscriber}
|
||||
onTestProfileChange={handleTestProfileChange}
|
||||
systemMessage={systemMessage}
|
||||
onSystemMessageChange={handleSystemMessageChange}
|
||||
mcpServerUrls={mcpServerUrls}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import { MCPServer, Message, PlaygroundChat, ToolMessage } from "@/app/lib/types
|
|||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
|
||||
import { Button } from "@heroui/react";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { ProfileContextBox } from "./profile-context-box";
|
||||
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { FeedbackModal } from "./feedback-modal";
|
||||
|
|
@ -21,8 +19,6 @@ export function Chat({
|
|||
projectId,
|
||||
workflow,
|
||||
messageSubscriber,
|
||||
testProfile = null,
|
||||
onTestProfileChange,
|
||||
systemMessage,
|
||||
onSystemMessageChange,
|
||||
mcpServerUrls,
|
||||
|
|
@ -37,8 +33,6 @@ export function Chat({
|
|||
projectId: string;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
|
||||
testProfile?: z.infer<typeof TestProfile> | null;
|
||||
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
|
||||
systemMessage: string;
|
||||
onSystemMessageChange: (message: string) => void;
|
||||
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
|
||||
|
|
@ -186,6 +180,7 @@ export function Chat({
|
|||
let streamId: string | null = null;
|
||||
try {
|
||||
const response = await getAssistantResponseStreamId(
|
||||
projectId,
|
||||
workflow,
|
||||
projectTools,
|
||||
[
|
||||
|
|
@ -306,20 +301,12 @@ export function Chat({
|
|||
systemMessage,
|
||||
mcpServerUrls,
|
||||
toolWebhookUrl,
|
||||
testProfile,
|
||||
fetchResponseError,
|
||||
projectTools,
|
||||
]);
|
||||
|
||||
return <div className="w-11/12 max-w-6xl mx-auto h-full flex flex-col relative">
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-zinc-900 pt-4 pb-4">
|
||||
{USE_TESTING_FEATURE && (
|
||||
<ProfileContextBox
|
||||
content={testProfile?.context || systemMessage || ''}
|
||||
onChange={onSystemMessageChange}
|
||||
locked={testProfile !== null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -334,7 +321,6 @@ export function Chat({
|
|||
toolCallResults={toolCallResults}
|
||||
loadingAssistantResponse={loadingAssistantResponse}
|
||||
workflow={workflow}
|
||||
testProfile={testProfile}
|
||||
systemMessage={systemMessage}
|
||||
onSystemMessageChange={onSystemMessageChange}
|
||||
showSystemMessage={false}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Workflow } from "@/app/lib/types/workflow_types";
|
|||
import { WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import MarkdownContent from "@/app/lib/components/markdown-content";
|
||||
import { ChevronRightIcon, ChevronDownIcon, ChevronUpIcon, CodeIcon, CheckCircleIcon, FileTextIcon, EyeIcon, EyeOffIcon, WrapTextIcon, ArrowRightFromLineIcon, BracesIcon, TextIcon, FlagIcon } from "lucide-react";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { ProfileContextBox } from "./profile-context-box";
|
||||
import { Message, ToolMessage, AssistantMessageWithToolCalls } from "@/app/lib/types/types";
|
||||
|
||||
|
|
@ -214,7 +213,6 @@ function ToolCalls({
|
|||
messages,
|
||||
sender,
|
||||
workflow,
|
||||
testProfile = null,
|
||||
systemMessage,
|
||||
delta,
|
||||
onFix,
|
||||
|
|
@ -227,7 +225,6 @@ function ToolCalls({
|
|||
messages: z.infer<typeof Message>[];
|
||||
sender: string | null | undefined;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
testProfile: z.infer<typeof TestProfile> | null;
|
||||
systemMessage: string | undefined;
|
||||
delta: number;
|
||||
onFix?: (message: string) => void;
|
||||
|
|
@ -576,7 +573,6 @@ export function Messages({
|
|||
toolCallResults,
|
||||
loadingAssistantResponse,
|
||||
workflow,
|
||||
testProfile = null,
|
||||
systemMessage,
|
||||
onSystemMessageChange,
|
||||
showSystemMessage,
|
||||
|
|
@ -589,7 +585,6 @@ export function Messages({
|
|||
toolCallResults: Record<string, z.infer<typeof ToolMessage>>;
|
||||
loadingAssistantResponse: boolean;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
testProfile: z.infer<typeof TestProfile> | null;
|
||||
systemMessage: string | undefined;
|
||||
onSystemMessageChange: (message: string) => void;
|
||||
showSystemMessage: boolean;
|
||||
|
|
@ -630,7 +625,6 @@ export function Messages({
|
|||
messages={messages}
|
||||
sender={message.agentName ?? ''}
|
||||
workflow={workflow}
|
||||
testProfile={testProfile}
|
||||
systemMessage={systemMessage}
|
||||
delta={latency}
|
||||
onFix={onFix}
|
||||
|
|
@ -694,9 +688,8 @@ export function Messages({
|
|||
if (showSystemMessage) {
|
||||
return (
|
||||
<ProfileContextBox
|
||||
content={testProfile?.context || systemMessage || ''}
|
||||
content={systemMessage || ''}
|
||||
onChange={onSystemMessageChange}
|
||||
locked={testProfile !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
"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 { usePathname } from "next/navigation";
|
||||
import { RunsApp } from "./runs_app";
|
||||
import { StructuredPanel } from "../../../../lib/components/structured-panel";
|
||||
import { ListItem } from "../../../../lib/components/structured-list";
|
||||
|
||||
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">
|
||||
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
|
||||
<div className="overflow-auto flex flex-col gap-1 justify-start">
|
||||
{menuItems.map((item) => (
|
||||
<ListItem
|
||||
key={item.label}
|
||||
name={item.label}
|
||||
isSelected={pathname.startsWith(item.href)}
|
||||
onClick={() => router.push(item.href)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
<div className="grow border-l border-gray-200 dark:border-neutral-800 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>;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// First, let's create a reusable component for item views
|
||||
export function ItemView({
|
||||
items,
|
||||
actions
|
||||
}: {
|
||||
items: { label: string; value: string | React.ReactNode }[];
|
||||
actions: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800 overflow-hidden">
|
||||
<div className="divide-y divide-gray-100 dark:divide-neutral-800">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-6 py-4 flex flex-col gap-1"
|
||||
>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-neutral-400">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-sm text-gray-900 dark:text-white">
|
||||
{item.value || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-800">
|
||||
<div className="flex gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { FormStatusButton } from "@/app/lib/components/form-status-button";
|
||||
import { Button, Input, Textarea, Switch } from "@heroui/react"
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface ProfileFormProps {
|
||||
defaultValues?: {
|
||||
name?: string;
|
||||
context?: string;
|
||||
mockTools?: boolean;
|
||||
mockPrompt?: string;
|
||||
};
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
handleSubmit: (formData: FormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitButtonText: string;
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
defaultValues = {},
|
||||
formRef,
|
||||
handleSubmit,
|
||||
onCancel,
|
||||
submitButtonText,
|
||||
}: ProfileFormProps) {
|
||||
const [mockTools, setMockTools] = useState(Boolean(defaultValues.mockTools));
|
||||
const [showMockPrompt, setShowMockPrompt] = useState(Boolean(defaultValues.mockTools));
|
||||
|
||||
return (
|
||||
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
|
||||
<Input
|
||||
label="Name"
|
||||
name="name"
|
||||
placeholder="Provide a name to describe the user's profile to simulate, e.g. "Frequent buyer""
|
||||
defaultValue={defaultValues.name}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Context"
|
||||
name="context"
|
||||
placeholder="Provide user info and other info to simulate, e.g. "User's name: John Smith. Buying frequency: 10 orders a month. Location: US. Latest order: Pair of Jeans - XL.""
|
||||
defaultValue={defaultValues.context}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<Switch
|
||||
isSelected={mockTools}
|
||||
onValueChange={(checked) => {
|
||||
setMockTools(checked);
|
||||
setShowMockPrompt(checked);
|
||||
}}
|
||||
name="mockTools"
|
||||
value="on"
|
||||
>
|
||||
Mock Tools
|
||||
</Switch>
|
||||
|
||||
{showMockPrompt && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-neutral-800 p-4">
|
||||
<div className="text-sm font-medium mb-2">Mock Prompt (Optional)</div>
|
||||
<Textarea
|
||||
name="mockPrompt"
|
||||
placeholder="Enter a mock prompt"
|
||||
defaultValue={defaultValues.mockPrompt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<FormStatusButton
|
||||
props={{
|
||||
children: submitButtonText,
|
||||
size: "md",
|
||||
color: "primary",
|
||||
type: "submit",
|
||||
className: "font-medium"
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
variant="flat"
|
||||
onPress={onCancel}
|
||||
className="font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { FormStatusButton } from "@/app/lib/components/form-status-button";
|
||||
import { Button, Input, Textarea } from "@heroui/react";
|
||||
|
||||
interface ScenarioFormProps {
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
handleSubmit: (formData: FormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitButtonText: string;
|
||||
defaultValues?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ScenarioForm({
|
||||
formRef,
|
||||
handleSubmit,
|
||||
onCancel,
|
||||
submitButtonText,
|
||||
defaultValues = {},
|
||||
}: ScenarioFormProps) {
|
||||
return (
|
||||
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="Provide a name for this scenario, e.g. "Order cancellation""
|
||||
defaultValue={defaultValues.name}
|
||||
isRequired
|
||||
classNames={{
|
||||
input: "bg-white dark:bg-neutral-900",
|
||||
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Describe the scenario that should be simulated, e.g. "Role play a user who wants to cancel their recently ordered pair of jeans.""
|
||||
defaultValue={defaultValues.description}
|
||||
isRequired
|
||||
classNames={{
|
||||
input: "bg-white dark:bg-neutral-900",
|
||||
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<FormStatusButton
|
||||
props={{
|
||||
children: submitButtonText,
|
||||
size: "md",
|
||||
color: "primary",
|
||||
type: "submit",
|
||||
className: "font-medium"
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
variant="flat"
|
||||
onPress={onCancel}
|
||||
className="font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
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 } from "@/components/ui/button";
|
||||
import { Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface ProfileSelectorProps {
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
|
||||
selectedProfileId?: string;
|
||||
}
|
||||
|
||||
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect, selectedProfileId }: 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 router = useRouter();
|
||||
|
||||
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" variant="primary" 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 dark:bg-gray-800 font-semibold text-sm rounded-t-md">
|
||||
<div className="col-span-2 px-4 text-gray-700 dark:text-gray-300">Name</div>
|
||||
<div className="col-span-3 px-4 text-gray-700 dark:text-gray-300">Context</div>
|
||||
<div className="col-span-1 px-4 text-gray-700 dark:text-gray-300">Mock Tools</div>
|
||||
</div>
|
||||
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p._id}
|
||||
className={`grid grid-cols-6 py-2.5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm cursor-pointer transition-colors ${
|
||||
p._id === selectedProfileId
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 border-blue-200 dark:border-blue-800'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelect(p);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="col-span-2 px-4 truncate text-gray-900 dark:text-gray-100">{p.name}</div>
|
||||
<div className="col-span-3 px-4 truncate text-gray-600 dark:text-gray-400">{p.context}</div>
|
||||
<div className="col-span-1 px-4 text-gray-600 dark:text-gray-400">{p.mockTools ? "Yes" : "No"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
{totalPages > 1 && <Pagination
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
className="self-center"
|
||||
/>}
|
||||
</>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="flex-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||
>
|
||||
Manage Profiles
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProfileId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
className="text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
|
||||
onClick={() => {
|
||||
onSelect(null);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
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 "@heroui/react";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
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 router = useRouter();
|
||||
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" onPress={() => 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 dark:bg-gray-800 font-semibold text-sm">
|
||||
<div className="col-span-2 px-4 text-gray-900 dark:text-gray-100">Name</div>
|
||||
<div className="col-span-3 px-4 text-gray-900 dark:text-gray-100">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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||
>
|
||||
Manage Scenarios
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
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 "@heroui/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" onPress={() => 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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 "@heroui/react";
|
||||
import { z } from "zod";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { WorkflowIcon } from "../../../../../../lib/components/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" onPress={() => 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import { FormStatusButton } from "@/app/lib/components/form-status-button-old";
|
||||
import { Button, Input, Textarea } from "@heroui/react";
|
||||
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { ScenarioSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/scenario-selector";
|
||||
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
|
||||
import { z } from "zod";
|
||||
|
||||
interface SimulationFormProps {
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
handleSubmit: (formData: FormData) => Promise<void>;
|
||||
scenario: WithStringId<z.infer<typeof TestScenario>> | null;
|
||||
setScenario: (scenario: WithStringId<z.infer<typeof TestScenario>> | null) => void;
|
||||
profile: WithStringId<z.infer<typeof TestProfile>> | null;
|
||||
setProfile: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
|
||||
isScenarioModalOpen: boolean;
|
||||
setIsScenarioModalOpen: (isOpen: boolean) => void;
|
||||
isProfileModalOpen: boolean;
|
||||
setIsProfileModalOpen: (isOpen: boolean) => void;
|
||||
projectId: string;
|
||||
submitButtonText: string;
|
||||
defaultValues?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
passCriteria?: string;
|
||||
};
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function SimulationForm({
|
||||
formRef,
|
||||
handleSubmit,
|
||||
scenario,
|
||||
setScenario,
|
||||
profile,
|
||||
setProfile,
|
||||
isScenarioModalOpen,
|
||||
setIsScenarioModalOpen,
|
||||
isProfileModalOpen,
|
||||
setIsProfileModalOpen,
|
||||
projectId,
|
||||
submitButtonText,
|
||||
defaultValues = {},
|
||||
onCancel,
|
||||
}: SimulationFormProps) {
|
||||
return (
|
||||
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="flex flex-col gap-4 p-4 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
|
||||
<h2 className="text-sm font-medium">Basic Information</h2>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label={<span>Name</span>}
|
||||
placeholder="Enter a name for the simulation, e.g. "Frequent buyer cancelling order""
|
||||
defaultValue={defaultValues.name}
|
||||
isRequired
|
||||
classNames={{
|
||||
input: "bg-white dark:bg-neutral-900",
|
||||
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
name="description"
|
||||
label={<span>Description</span>}
|
||||
placeholder="Enter an optional description for the simulation, just to help you remember what it's for"
|
||||
defaultValue={defaultValues.description}
|
||||
classNames={{
|
||||
input: "bg-white dark:bg-neutral-900",
|
||||
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Configuration */}
|
||||
<div className="flex flex-col gap-6 p-6 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Test Configuration</h2>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Scenario Selection */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Scenario <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5 min-h-[2rem]">
|
||||
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
{scenario ? (
|
||||
<span className="text-blue-600 dark:text-blue-400">{scenario.name}</span>
|
||||
) : (
|
||||
<span className="text-red-500">No scenario selected</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => setIsScenarioModalOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{scenario ? "Change" : "Select"} Scenario
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Selection */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Profile <span className="text-gray-500 dark:text-neutral-400">(optional)</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5 min-h-[2rem]">
|
||||
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
{profile ? (
|
||||
<span className="text-blue-600 dark:text-blue-400">{profile.name}</span>
|
||||
) : (
|
||||
"No profile selected"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{profile && (
|
||||
<Button size="sm" variant="bordered" onClick={() => setProfile(null)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => setIsProfileModalOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{profile ? "Change" : "Select"} Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pass Criteria */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Pass Criteria <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
name="passCriteria"
|
||||
placeholder="Define the criteria for this test to pass, e.g. "The assistant should successfully cancel the user's order and provide next steps for the user to confirm the cancellation""
|
||||
defaultValue={defaultValues.passCriteria}
|
||||
isRequired
|
||||
minRows={3}
|
||||
classNames={{
|
||||
base: "w-full",
|
||||
input: "bg-white dark:bg-neutral-900 resize-none",
|
||||
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:border-gray-300 dark:hover:border-neutral-700 transition-colors"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-3">
|
||||
<FormStatusButton
|
||||
props={{
|
||||
children: submitButtonText,
|
||||
size: "md",
|
||||
color: "primary",
|
||||
type: "submit",
|
||||
isDisabled: !scenario,
|
||||
className: "font-medium"
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
variant="flat"
|
||||
onPress={onCancel}
|
||||
className="font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScenarioSelector
|
||||
projectId={projectId}
|
||||
isOpen={isScenarioModalOpen}
|
||||
onOpenChange={setIsScenarioModalOpen}
|
||||
onSelect={setScenario}
|
||||
/>
|
||||
<ProfileSelector
|
||||
projectId={projectId}
|
||||
isOpen={isProfileModalOpen}
|
||||
onOpenChange={setIsProfileModalOpen}
|
||||
onSelect={setProfile}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Selection } from "@heroui/react";
|
||||
import { Button } from "@heroui/react";
|
||||
import { PencilIcon, TrashIcon, EyeIcon, DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
|
||||
|
||||
// Helper function to safely parse dates
|
||||
const isValidDate = (date: any): boolean => {
|
||||
const parsed = new Date(date);
|
||||
return parsed instanceof Date && !isNaN(parsed.getTime());
|
||||
};
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (item: any) => ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
items: any[];
|
||||
columns: Column[];
|
||||
selectedKeys?: Selection;
|
||||
onSelectionChange?: (keys: Selection) => void;
|
||||
projectId: string;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
onEdit?: (id: string) => void;
|
||||
onView?: (id: string) => void;
|
||||
onDownload?: (id: string) => void;
|
||||
selectionMode?: "multiple" | "none";
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
items,
|
||||
columns,
|
||||
selectedKeys,
|
||||
onSelectionChange,
|
||||
projectId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onView,
|
||||
onDownload,
|
||||
selectionMode = "multiple",
|
||||
}: DataTableProps) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
|
||||
const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setItemToDelete(id);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!itemToDelete || !onDelete) return;
|
||||
|
||||
try {
|
||||
await onDelete(itemToDelete);
|
||||
setIsDeleteModalOpen(false);
|
||||
setItemToDelete(null);
|
||||
} catch (error) {
|
||||
setDeleteError(`Failed to delete: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (!onDelete) return;
|
||||
|
||||
try {
|
||||
// Delete all items sequentially
|
||||
for (const item of items) {
|
||||
await onDelete(item._id);
|
||||
}
|
||||
setIsDeleteAllModalOpen(false);
|
||||
// Selection will be cleared automatically when items refresh
|
||||
} catch (error) {
|
||||
setDeleteError(`Failed to delete items: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = selectedKeys === "all";
|
||||
|
||||
const renderCells = (item: any) => {
|
||||
const cells = columns.map(column => (
|
||||
<TableCell key={column.key}>
|
||||
{column.render ? column.render(item) :
|
||||
// Handle date fields specially
|
||||
(column.key.toLowerCase().includes('date') ||
|
||||
column.key === 'createdAt' ||
|
||||
column.key === 'lastUpdatedAt') && isValidDate(item[column.key]) ?
|
||||
new Date(item[column.key]).toLocaleString() :
|
||||
item[column.key]
|
||||
}
|
||||
</TableCell>
|
||||
));
|
||||
|
||||
// Only add actions column if there are any actions
|
||||
const hasActions = onDelete || onEdit || onView || onDownload;
|
||||
if (hasActions) {
|
||||
cells.push(
|
||||
<TableCell key="actions">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onView && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => onView(item._id)}
|
||||
aria-label="View item"
|
||||
>
|
||||
<EyeIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => onEdit(item._id)}
|
||||
aria-label="Edit item"
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => onDownload(item._id)}
|
||||
aria-label="Download results"
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="danger"
|
||||
onPress={() => handleDeleteClick(item._id)}
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return cells;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Only show Delete All button when selection is enabled and items are selected */}
|
||||
{selectionMode === "multiple" && selectedKeys === "all" && items.length > 0 && (
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => setIsDeleteAllModalOpen(true)}
|
||||
startContent={<TrashIcon size={16} />}
|
||||
>
|
||||
Delete All ({items.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
selectedKeys={selectionMode === "multiple" ? selectedKeys : undefined}
|
||||
onSelectionChange={selectionMode === "multiple" ? onSelectionChange : undefined}
|
||||
aria-label="Data table"
|
||||
classNames={{
|
||||
base: "max-h-[400px] overflow-auto",
|
||||
table: "min-w-full",
|
||||
}}
|
||||
selectionMode={selectionMode}
|
||||
>
|
||||
<TableHeader columns={[
|
||||
...columns.map(column => ({
|
||||
key: column.key,
|
||||
label: column.label
|
||||
})),
|
||||
...((onDelete || onEdit || onView || onDownload) ? [{
|
||||
key: 'actions',
|
||||
label: 'ACTIONS',
|
||||
render: (item: any) => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="danger"
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}] : [])
|
||||
]}>
|
||||
{(column) => (
|
||||
<TableColumn key={column.key}>{column.label}</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody items={items}>
|
||||
{(item) => (
|
||||
<TableRow key={item._id}>
|
||||
{renderCells(item)}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Single Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteModalOpen(open);
|
||||
if (!open) setItemToDelete(null);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>Confirm Deletion</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete this item?
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="flat" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
handleDeleteConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete All Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={isDeleteAllModalOpen}
|
||||
onOpenChange={setIsDeleteAllModalOpen}
|
||||
size="sm"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>Confirm Delete All</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete all {items.length} items? This action cannot be undone.
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="flat" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
handleDeleteAll();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Delete All
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Error Modal */}
|
||||
<Modal
|
||||
isOpen={deleteError !== null}
|
||||
onOpenChange={() => setDeleteError(null)}
|
||||
size="sm"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>Error</ModalHeader>
|
||||
<ModalBody>
|
||||
{deleteError}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { ScenariosApp } from "./scenarios_app";
|
||||
import { SimulationsApp } from "./simulations_app";
|
||||
import { ProfilesApp } from "./profiles_app";
|
||||
import { RunsApp } from "./runs_app";
|
||||
import { TestingMenu } from "./testing_menu";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default async function TestPage(props: { params: Promise<{ projectId: string; slug?: string[] }> }) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
const { projectId, slug = [] } = params;
|
||||
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
|
||||
|
||||
if (slug[0] === "scenarios") {
|
||||
app = "scenarios";
|
||||
} else if (slug[0] === "simulations") {
|
||||
app = "simulations";
|
||||
} else if (slug[0] === "profiles") {
|
||||
app = "profiles";
|
||||
} else if (slug[0] === "runs") {
|
||||
app = "runs";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize={15} minSize={10}>
|
||||
<div className="h-full border-r border-gray-200 dark:border-neutral-800">
|
||||
<TestingMenu projectId={projectId} app={app} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={85}>
|
||||
{app === "scenarios" && <ScenariosApp projectId={projectId} slug={slug.slice(1)} />}
|
||||
{app === "simulations" && <SimulationsApp projectId={projectId} slug={slug.slice(1)} />}
|
||||
{app === "profiles" && <ProfilesApp projectId={projectId} slug={slug.slice(1)} />}
|
||||
{app === "runs" && <RunsApp projectId={projectId} slug={slug.slice(1)} />}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
"use client";
|
||||
|
||||
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 } from "@/app/actions/testing_actions";
|
||||
import { Button, Spinner, Selection } from "@heroui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { RelativeTime } from "@primer/react"
|
||||
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
|
||||
import { DataTable } from "./components/table";
|
||||
import { isValidDate } from './utils/date';
|
||||
import { ProfileForm } from "./components/profile-form";
|
||||
|
||||
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 mockTools = formData.get("mockTools") === "on";
|
||||
const mockPrompt = formData.get("mockPrompt") as string;
|
||||
|
||||
await updateProfile(projectId, profileId, {
|
||||
name,
|
||||
context,
|
||||
mockTools,
|
||||
mockPrompt: mockTools && mockPrompt ? mockPrompt : undefined
|
||||
});
|
||||
router.push(`/projects/${projectId}/test/profiles`);
|
||||
} catch (error) {
|
||||
setError(`Unable to update profile: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="EDIT PROFILE"
|
||||
tooltip="Edit an existing test profile"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
{loading && (
|
||||
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading profile...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && profile && (
|
||||
<ProfileForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||
submitButtonText="Update Profile"
|
||||
defaultValues={{
|
||||
name: profile.name,
|
||||
context: profile.context,
|
||||
mockTools: Boolean(profile.mockTools),
|
||||
mockPrompt: profile.mockPrompt || ""
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
function NewProfile({ projectId }: { projectId: string }) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
try {
|
||||
const name = formData.get("name") as string;
|
||||
const context = formData.get("context") as string;
|
||||
const mockTools = formData.get("mockTools") === "on";
|
||||
const mockPrompt = mockTools ? (formData.get("mockPrompt") as string) : undefined;
|
||||
|
||||
await createProfile(projectId, {
|
||||
name,
|
||||
context,
|
||||
mockTools,
|
||||
mockPrompt // This will be undefined if mockTools is false
|
||||
});
|
||||
router.push(`/projects/${projectId}/test/profiles`);
|
||||
} catch (error) {
|
||||
setError(`Unable to create profile: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="NEW PROFILE"
|
||||
tooltip="Create a new test profile"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
<ProfileForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||
submitButtonText="Create Profile"
|
||||
/>
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
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 [total, setTotal] = useState(0);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchProfiles() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const profiles = await listProfiles(projectId, page, pageSize);
|
||||
if (!ignore) {
|
||||
setProfiles(profiles.profiles);
|
||||
setTotal(Math.ceil(profiles.total / pageSize));
|
||||
}
|
||||
} 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]);
|
||||
|
||||
const handleSelectionChange = (selection: Selection) => {
|
||||
if (selection === "all" &&
|
||||
selectedKeys !== "all" &&
|
||||
(selectedKeys as Set<string>).size > 0) {
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedProfiles([]);
|
||||
} else {
|
||||
setSelectedKeys(selection);
|
||||
if (selection === "all") {
|
||||
setSelectedProfiles(profiles.map(profile => profile._id));
|
||||
} else {
|
||||
setSelectedProfiles(Array.from(selection as Set<string>));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (profileId: string) => {
|
||||
try {
|
||||
await deleteProfile(projectId, profileId);
|
||||
// Refresh the profiles list after deletion
|
||||
const result = await listProfiles(projectId, page, pageSize);
|
||||
setProfiles(result.profiles);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
setError(`Failed to delete profile: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'NAME',
|
||||
render: (profile: any) => profile.name
|
||||
},
|
||||
{
|
||||
key: 'context',
|
||||
label: 'CONTEXT'
|
||||
},
|
||||
{
|
||||
key: 'mockTools',
|
||||
label: 'MOCK TOOLS',
|
||||
render: (profile: any) => profile.mockTools ? "Yes" : "No"
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'CREATED',
|
||||
render: (profile: any) => profile?.createdAt && isValidDate(profile.createdAt) ?
|
||||
<RelativeTime date={new Date(profile.createdAt)} /> :
|
||||
'Invalid date'
|
||||
},
|
||||
{
|
||||
key: 'lastUpdatedAt',
|
||||
label: 'LAST UPDATED',
|
||||
render: (profile: any) => profile?.lastUpdatedAt && isValidDate(profile.lastUpdatedAt) ?
|
||||
<RelativeTime date={new Date(profile.lastUpdatedAt)} /> :
|
||||
'Invalid date'
|
||||
}
|
||||
];
|
||||
|
||||
return <StructuredPanel
|
||||
title="PROFILES"
|
||||
tooltip="View and manage your test profiles"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Profiles</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Create and manage test profiles for your simulations
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
|
||||
>
|
||||
New Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profiles Table */}
|
||||
{loading ? (
|
||||
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading profiles...
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
|
||||
<p className="text-gray-600 dark:text-neutral-400">No profiles created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
items={profiles}
|
||||
columns={columns}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(id) => router.push(`/projects/${projectId}/test/profiles/${id}/edit`)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
export function ProfilesApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
|
||||
let selection: "list" | "new" | "edit" = "list";
|
||||
let profileId: string | undefined;
|
||||
|
||||
if (slug && slug.length > 0) {
|
||||
if (slug[0] === "new") {
|
||||
selection = "new";
|
||||
} else if (slug[1] === "edit") {
|
||||
selection = "edit";
|
||||
profileId = slug[0];
|
||||
} else {
|
||||
selection = "list";
|
||||
profileId = slug[0];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{selection === "list" && <ProfileList projectId={projectId} />}
|
||||
{selection === "new" && <NewProfile projectId={projectId} />}
|
||||
{selection === "edit" && profileId && (
|
||||
<EditProfile projectId={projectId} profileId={profileId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NewProfile, EditProfile };
|
||||
|
|
@ -1,457 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRun, getSimulation, listRuns, cancelRun, deleteRun, getSimulationResult, listRunSimulations } from "@/app/actions/testing_actions";
|
||||
import { Button, Spinner, Selection } from "@heroui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { ArrowLeftIcon, PlusIcon, DownloadIcon } from "lucide-react";
|
||||
import { RelativeTime } from "@primer/react"
|
||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||
import { fetchWorkflow } from "@/app/actions/workflow_actions";
|
||||
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel"
|
||||
import { DataTable } from "./components/table"
|
||||
import { isValidDate } from './utils/date';
|
||||
|
||||
function ViewRun({
|
||||
projectId,
|
||||
runId,
|
||||
}: {
|
||||
projectId: string,
|
||||
runId: string,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
|
||||
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const run = await getRun(projectId, runId);
|
||||
if (!run) {
|
||||
setError("Run not found");
|
||||
return;
|
||||
}
|
||||
setRun(run);
|
||||
|
||||
const enrichedSimulations = await listRunSimulations(projectId, run.simulationIds);
|
||||
setSimulations(enrichedSimulations);
|
||||
|
||||
// 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);
|
||||
} catch (error) {
|
||||
setError(`Error fetching run: ${error}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [projectId, runId]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'SIMULATION',
|
||||
render: (simulation: any) => simulation.name
|
||||
},
|
||||
{
|
||||
key: 'scenarioId',
|
||||
label: 'SCENARIO',
|
||||
render: (simulation: any) => simulation.scenarioName
|
||||
},
|
||||
{
|
||||
key: 'profileId',
|
||||
label: 'PROFILE',
|
||||
render: (simulation: any) => simulation.profileName
|
||||
}
|
||||
];
|
||||
|
||||
const handleDownload = async (simulationId: string) => {
|
||||
try {
|
||||
const result = await getSimulationResult(projectId, runId, simulationId);
|
||||
if (!result) {
|
||||
console.error("No result found for simulation");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get simulation name from simulations array
|
||||
const simulation = simulations.find(s => s._id === simulationId);
|
||||
if (!simulation) {
|
||||
console.error("Simulation not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a safe filename
|
||||
const safeName = `${run?.name}_${simulation.name}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||
|
||||
// Create the JSON content
|
||||
const content = {
|
||||
run: run?.name,
|
||||
simulation: simulation.name,
|
||||
result: result.result,
|
||||
details: result.details,
|
||||
transcript: result.transcript
|
||||
};
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([JSON.stringify(content, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${safeName}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("Failed to download result:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return <StructuredPanel
|
||||
title="VIEW RUN"
|
||||
tooltip="View details of this test run"
|
||||
actions={[
|
||||
<ActionButton
|
||||
key="back"
|
||||
icon={<ArrowLeftIcon size={16} />}
|
||||
onClick={() => router.push(`/projects/${projectId}/test/runs`)}
|
||||
>
|
||||
All Runs
|
||||
</ActionButton>
|
||||
]}
|
||||
>
|
||||
{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 && (
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
{/* 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?.passCount || 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?.failCount || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simulations List */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
|
||||
<DataTable
|
||||
items={simulations}
|
||||
columns={columns}
|
||||
projectId={projectId}
|
||||
onDownload={handleDownload}
|
||||
selectionMode="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StructuredPanel>
|
||||
}
|
||||
|
||||
function RunsList({ 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);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||
const [selectedRuns, setSelectedRuns] = useState<string[]>([]);
|
||||
|
||||
const handleSelectionChange = (selection: Selection) => {
|
||||
if (selection === "all" &&
|
||||
selectedKeys !== "all" &&
|
||||
(selectedKeys as Set<string>).size > 0) {
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedRuns([]);
|
||||
} else {
|
||||
setSelectedKeys(selection);
|
||||
if (selection === "all") {
|
||||
setSelectedRuns(runs.map(run => run._id));
|
||||
} else {
|
||||
setSelectedRuns(Array.from(selection as Set<string>));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (runId: string) => {
|
||||
try {
|
||||
await cancelRun(projectId, runId);
|
||||
// Update the run status locally after successful cancellation
|
||||
setRuns(runs.map(run => {
|
||||
if (run._id === runId) {
|
||||
return {
|
||||
...run,
|
||||
status: 'cancelled'
|
||||
};
|
||||
}
|
||||
return run;
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(`Failed to cancel run: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (runId: string) => {
|
||||
try {
|
||||
await deleteRun(projectId, runId);
|
||||
// Refresh the runs list after deletion
|
||||
const updatedRuns = await listRuns(projectId, page, pageSize);
|
||||
setRuns(updatedRuns.runs);
|
||||
setTotal(updatedRuns.total);
|
||||
} catch (err) {
|
||||
setError(`Failed to delete run: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'NAME',
|
||||
render: (run: any) => run.name
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'STATUS',
|
||||
render: (run: any) => (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusStyles(run.status)}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${getStatusDotStyles(run.status)}`} />
|
||||
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'results',
|
||||
label: 'RESULTS',
|
||||
render: (run: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600 dark:text-green-400">{run.passCount || 0} passed</span>
|
||||
<span className="text-red-600 dark:text-red-400">{run.failCount || 0} failed</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'STARTED',
|
||||
render: (run: any) => isValidDate(run.startedAt) ?
|
||||
<RelativeTime date={new Date(run.startedAt)} /> :
|
||||
'Invalid date'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StructuredPanel
|
||||
title="TEST RUNS"
|
||||
tooltip="View and manage your test runs"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Test Runs</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
View and monitor your workflow test runs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
|
||||
>
|
||||
New Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Runs Table */}
|
||||
{loading ? (
|
||||
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading test runs...
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
|
||||
<p className="text-gray-600 dark:text-neutral-400">No test runs created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
items={runs}
|
||||
columns={columns}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDelete={handleDelete}
|
||||
onView={(id) => router.push(`/projects/${projectId}/test/runs/${id}`)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for status styling
|
||||
function getStatusStyles(status: string): string {
|
||||
const styles = {
|
||||
pending: "bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300",
|
||||
running: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
completed: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
cancelled: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
};
|
||||
return styles[status as keyof typeof styles] || styles.pending;
|
||||
}
|
||||
|
||||
function getStatusDotStyles(status: string): string {
|
||||
const styles = {
|
||||
pending: "bg-gray-500 dark:bg-neutral-400",
|
||||
running: "bg-blue-500 dark:bg-blue-400",
|
||||
completed: "bg-green-500 dark:bg-green-400",
|
||||
cancelled: "bg-yellow-500 dark:bg-yellow-400",
|
||||
failed: "bg-red-500 dark:bg-red-400",
|
||||
error: "bg-red-500 dark:bg-red-400"
|
||||
};
|
||||
return styles[status as keyof typeof styles] || styles.pending;
|
||||
}
|
||||
|
||||
export function RunsApp({
|
||||
projectId,
|
||||
slug
|
||||
}: {
|
||||
projectId: string,
|
||||
slug: string[]
|
||||
}) {
|
||||
let selection: "list" | "view" = "list";
|
||||
let runId: string | null = null;
|
||||
if (slug.length > 0) {
|
||||
selection = "view";
|
||||
runId = slug[0];
|
||||
}
|
||||
|
||||
return <>
|
||||
{selection === "list" && <RunsList projectId={projectId} />}
|
||||
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
|
||||
</>;
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
"use client";
|
||||
|
||||
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, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Selection } from "@heroui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { ArrowLeftIcon, PlusIcon, } from "lucide-react";
|
||||
import { RelativeTime } from "@primer/react"
|
||||
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
|
||||
import { DataTable } from "./components/table";
|
||||
import { isValidDate } from './utils/date';
|
||||
import { ItemView } from "./components/item-view"
|
||||
import { ScenarioForm } from "./components/scenario-form";
|
||||
|
||||
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`);
|
||||
} catch (error) {
|
||||
setError(`Unable to update scenario: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="EDIT SCENARIO"
|
||||
tooltip="Edit an existing test scenario"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
{loading && (
|
||||
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading scenario...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && scenario && (
|
||||
<ScenarioForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||
submitButtonText="Update Scenario"
|
||||
defaultValues={{
|
||||
name: scenario.name,
|
||||
description: scenario.description
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<StructuredPanel
|
||||
title="VIEW SCENARIO"
|
||||
tooltip="View scenario details"
|
||||
actions={[
|
||||
<ActionButton
|
||||
key="back"
|
||||
icon={<ArrowLeftIcon size={16} />}
|
||||
onClick={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||
>
|
||||
All Scenarios
|
||||
</ActionButton>
|
||||
]}
|
||||
>
|
||||
<ItemView
|
||||
items={[
|
||||
{ label: "Name", value: scenario?.name },
|
||||
{ label: "Description", value: scenario?.description },
|
||||
{
|
||||
label: "Created",
|
||||
value: scenario?.createdAt && isValidDate(scenario.createdAt)
|
||||
? <RelativeTime date={new Date(scenario.createdAt)} />
|
||||
: 'Invalid date'
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: scenario?.lastUpdatedAt && isValidDate(scenario.lastUpdatedAt)
|
||||
? <RelativeTime date={new Date(scenario.lastUpdatedAt)} />
|
||||
: 'Invalid date'
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="flat" onPress={() => router.push(`/projects/${projectId}/test/scenarios/${scenarioId}/edit`)}>Edit</Button>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => setIsDeleteModalOpen(true)}>Delete</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</StructuredPanel>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
await createScenario(projectId, { name, description });
|
||||
router.push(`/projects/${projectId}/test/scenarios`);
|
||||
} catch (error) {
|
||||
setError(`Unable to create scenario: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="NEW SCENARIO"
|
||||
tooltip="Create a new test scenario"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScenarioForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||
submitButtonText="Create Scenario"
|
||||
/>
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
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);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSelectionChange = (selection: Selection) => {
|
||||
if (selection === "all" &&
|
||||
selectedKeys !== "all" &&
|
||||
(selectedKeys as Set<string>).size > 0) {
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedScenarios([]);
|
||||
} else {
|
||||
setSelectedKeys(selection);
|
||||
if (selection === "all") {
|
||||
setSelectedScenarios(scenarios.map(scenario => scenario._id));
|
||||
} else {
|
||||
setSelectedScenarios(Array.from(selection as Set<string>));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (scenarioId: string) => {
|
||||
try {
|
||||
await deleteScenario(projectId, scenarioId);
|
||||
// Refresh the scenarios list after deletion
|
||||
const result = await listScenarios(projectId, page, pageSize);
|
||||
setScenarios(result.scenarios);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
setError(`Failed to delete scenario: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'NAME',
|
||||
render: (scenario: any) => scenario.name
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'DESCRIPTION'
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'CREATED',
|
||||
render: (scenario: any) => isValidDate(scenario.createdAt) ?
|
||||
<RelativeTime date={new Date(scenario.createdAt)} /> :
|
||||
'Invalid date'
|
||||
}
|
||||
];
|
||||
|
||||
return <StructuredPanel
|
||||
title="SCENARIOS"
|
||||
tooltip="View and manage your test scenarios"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Scenarios</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Create and manage test scenarios for your simulations
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
|
||||
>
|
||||
New Scenario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scenarios Table */}
|
||||
{loading ? (
|
||||
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading scenarios...
|
||||
</div>
|
||||
) : scenarios.length === 0 ? (
|
||||
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
|
||||
<p className="text-gray-600 dark:text-neutral-400">No scenarios created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
items={scenarios}
|
||||
columns={columns}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(id) => router.push(`/projects/${projectId}/test/scenarios/${id}/edit`)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
export function ScenariosApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
|
||||
let selection: "list" | "new" | "edit" = "list";
|
||||
let scenarioId: string | undefined;
|
||||
|
||||
if (slug && slug.length > 0) {
|
||||
if (slug[0] === "new") {
|
||||
selection = "new";
|
||||
} else if (slug[1] === "edit") {
|
||||
selection = "edit";
|
||||
scenarioId = slug[0];
|
||||
} else {
|
||||
selection = "list";
|
||||
scenarioId = slug[0];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{selection === "list" && <ScenarioList projectId={projectId} />}
|
||||
{selection === "new" && <NewScenario projectId={projectId} />}
|
||||
{selection === "edit" && scenarioId && (
|
||||
<EditScenario projectId={projectId} scenarioId={scenarioId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { TestProfile, TestScenario, TestSimulation, TestRun } from "@/app/lib/types/testing_types";
|
||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { createSimulation, getSimulation, listSimulations, updateSimulation, deleteSimulation, getScenario, getProfile, createRun } from "@/app/actions/testing_actions";
|
||||
import { Button, Spinner, Tooltip, Selection } from "@heroui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { PlusIcon, ArrowLeftIcon, AlertTriangleIcon } from "lucide-react";
|
||||
import { RelativeTime } from "@primer/react"
|
||||
import { ScenarioSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/scenario-selector";
|
||||
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
|
||||
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
|
||||
import { WorkflowSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/workflow-selector";
|
||||
import { DataTable } from "./components/table"
|
||||
import { isValidDate } from './utils/date';
|
||||
import { SimulationForm } from "./components/simulation-form";
|
||||
|
||||
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 [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);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
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),
|
||||
simulation.profileId ? getProfile(projectId, simulation.profileId) : Promise.resolve(null),
|
||||
]);
|
||||
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 description = formData.get("description") as string;
|
||||
const passCriteria = formData.get("passCriteria") as string;
|
||||
|
||||
if (!scenario) {
|
||||
throw new Error("Please select a scenario");
|
||||
}
|
||||
|
||||
await updateSimulation(projectId, simulationId, {
|
||||
name,
|
||||
description,
|
||||
scenarioId: scenario._id,
|
||||
profileId: profile?._id || null,
|
||||
passCriteria
|
||||
});
|
||||
router.push(`/projects/${projectId}/test/simulations`);
|
||||
} catch (error) {
|
||||
setError(`Unable to update simulation: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="EDIT SIMULATION"
|
||||
tooltip="Edit an existing test simulation"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
{loading && (
|
||||
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading simulation...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && simulation && (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Edit Simulation</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-400">
|
||||
Define a test simulation by selecting a scenario and optionally a profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SimulationForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
scenario={scenario}
|
||||
setScenario={setScenario}
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
isScenarioModalOpen={isScenarioModalOpen}
|
||||
setIsScenarioModalOpen={setIsScenarioModalOpen}
|
||||
isProfileModalOpen={isProfileModalOpen}
|
||||
setIsProfileModalOpen={setIsProfileModalOpen}
|
||||
projectId={projectId}
|
||||
submitButtonText="Update Simulation"
|
||||
defaultValues={{
|
||||
name: simulation.name ?? '',
|
||||
description: simulation.description ?? '',
|
||||
passCriteria: simulation.passCriteria ?? ''
|
||||
}}
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/simulations`)}
|
||||
/>
|
||||
|
||||
<ScenarioSelector
|
||||
projectId={projectId}
|
||||
isOpen={isScenarioModalOpen}
|
||||
onOpenChange={setIsScenarioModalOpen}
|
||||
onSelect={setScenario}
|
||||
/>
|
||||
<ProfileSelector
|
||||
projectId={projectId}
|
||||
isOpen={isProfileModalOpen}
|
||||
onOpenChange={setIsProfileModalOpen}
|
||||
onSelect={setProfile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
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 description = formData.get("description") 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");
|
||||
}
|
||||
|
||||
const result = await createSimulation(projectId, {
|
||||
name,
|
||||
description,
|
||||
scenarioId: scenario._id,
|
||||
profileId: profile?._id || null,
|
||||
passCriteria,
|
||||
});
|
||||
router.push(`/projects/${projectId}/test/simulations/${result._id}`);
|
||||
} catch (error) {
|
||||
setError(`Unable to create simulation: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return <StructuredPanel
|
||||
title="NEW SIMULATION"
|
||||
tooltip="Create a new test simulation"
|
||||
actions={[
|
||||
<ActionButton
|
||||
key="back"
|
||||
icon={<ArrowLeftIcon size={16} />}
|
||||
onClick={() => router.push(`/projects/${projectId}/test/simulations`)}
|
||||
>
|
||||
All Simulations
|
||||
</ActionButton>
|
||||
]}
|
||||
>
|
||||
<div className="h-full flex flex-col gap-6 max-w-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Simulation</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Define a new test simulation by selecting a scenario and optionally a profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
|
||||
</div>}
|
||||
|
||||
<SimulationForm
|
||||
formRef={formRef}
|
||||
handleSubmit={handleSubmit}
|
||||
scenario={scenario}
|
||||
setScenario={setScenario}
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
isScenarioModalOpen={isScenarioModalOpen}
|
||||
setIsScenarioModalOpen={setIsScenarioModalOpen}
|
||||
isProfileModalOpen={isProfileModalOpen}
|
||||
setIsProfileModalOpen={setIsProfileModalOpen}
|
||||
projectId={projectId}
|
||||
submitButtonText="Create Simulation"
|
||||
onCancel={() => router.push(`/projects/${projectId}/test/simulations`)}
|
||||
/>
|
||||
</div>
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
||||
function SimulationList({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = 10;
|
||||
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
|
||||
const [selectedSimulations, setSelectedSimulations] = useState<string[]>([]);
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
|
||||
const [isWorkflowSelectorOpen, setIsWorkflowSelectorOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [simulationDetails, setSimulationDetails] = useState<Record<string, {
|
||||
scenario?: WithStringId<z.infer<typeof TestScenario>>,
|
||||
profile?: WithStringId<z.infer<typeof TestProfile>>
|
||||
}>>({});
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchSimulations() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listSimulations(projectId, page, pageSize);
|
||||
if (!ignore) {
|
||||
setSimulations(result.simulations);
|
||||
setTotal(result.total);
|
||||
|
||||
// Fetch scenario and profile details for each simulation
|
||||
const details: Record<string, any> = {};
|
||||
await Promise.all(result.simulations.map(async (simulation) => {
|
||||
const [scenarioResult, profileResult] = await Promise.all([
|
||||
getScenario(projectId, simulation.scenarioId),
|
||||
simulation.profileId ? getProfile(projectId, simulation.profileId) : Promise.resolve(null),
|
||||
]);
|
||||
if (!ignore) {
|
||||
details[simulation._id] = {
|
||||
scenario: scenarioResult,
|
||||
profile: profileResult
|
||||
};
|
||||
}
|
||||
}));
|
||||
if (!ignore) {
|
||||
setSimulationDetails(details);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
setError(`Unable to fetch simulations: ${error}`);
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimulations();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [projectId, page, pageSize]);
|
||||
|
||||
const handleSelectionChange = (selection: Selection) => {
|
||||
setSelectedKeys(selection);
|
||||
if (selection === "all") {
|
||||
setSelectedSimulations(simulations.map(sim => sim._id));
|
||||
} else {
|
||||
setSelectedSimulations(Array.from(selection as Set<string>));
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateRun() {
|
||||
if (!selectedWorkflow || selectedSimulations.length === 0) {
|
||||
return; // Just return without setting error
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await createRun(projectId, {
|
||||
workflowId: selectedWorkflow._id,
|
||||
simulationIds: selectedSimulations
|
||||
});
|
||||
|
||||
setSelectedSimulations([]);
|
||||
setSelectedWorkflow(null);
|
||||
|
||||
router.push(`/projects/${projectId}/test/runs/${run._id}`);
|
||||
} catch (err) {
|
||||
setError(`Failed to create test run: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (simulationId: string) => {
|
||||
try {
|
||||
await deleteSimulation(projectId, simulationId);
|
||||
// Refresh the simulations list after deletion
|
||||
const result = await listSimulations(projectId, page, pageSize);
|
||||
setSimulations(result.simulations);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
setError(`Failed to delete simulation: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunchClick = () => {
|
||||
if (!selectedWorkflow || selectedSimulations.length === 0) {
|
||||
alert("Please select a workflow version and at least one simulation.");
|
||||
} else {
|
||||
handleCreateRun();
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'NAME',
|
||||
render: (simulation: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{simulation.name}</span>
|
||||
{(!simulationDetails[simulation._id]?.scenario ||
|
||||
(simulation.profileId && !simulationDetails[simulation._id]?.profile)) && (
|
||||
<Tooltip content="Associated scenario or profile has been deleted">
|
||||
<AlertTriangleIcon
|
||||
size={16}
|
||||
className="text-amber-500 dark:text-amber-400"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'scenarioId',
|
||||
label: 'SCENARIO',
|
||||
render: (simulation: any) => {
|
||||
const details = simulationDetails[simulation._id];
|
||||
if (!details?.scenario) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-amber-500 dark:text-amber-400">
|
||||
<Tooltip content="This scenario has been deleted">
|
||||
<AlertTriangleIcon size={14} />
|
||||
</Tooltip>
|
||||
<span>Deleted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return details.scenario.name;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'profileId',
|
||||
label: 'PROFILE',
|
||||
render: (simulation: any) => {
|
||||
const details = simulationDetails[simulation._id];
|
||||
if (simulation.profileId && !details?.profile) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-amber-500 dark:text-amber-400">
|
||||
<Tooltip content="This profile has been deleted">
|
||||
<AlertTriangleIcon size={14} />
|
||||
</Tooltip>
|
||||
<span>Deleted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return details?.profile?.name || 'None';
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'CREATED',
|
||||
render: (simulation: any) => isValidDate(simulation.createdAt) ?
|
||||
<RelativeTime date={new Date(simulation.createdAt)} /> :
|
||||
'Invalid date'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StructuredPanel
|
||||
title="SIMULATIONS"
|
||||
tooltip="View and manage your test simulations"
|
||||
>
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
{/* Combined Guidance and Run Creation Section */}
|
||||
<div className="flex flex-col gap-4 p-6 bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create a Test Run
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Step 1: Create New Simulation */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Create a New Simulation (Optional)
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
Define a new test simulation if needed
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={() => router.push(`/projects/${projectId}/test/simulations/new`)}
|
||||
>
|
||||
New Simulation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Select Workflow Version */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Select workflow version
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedWorkflow ? "solid" : "flat"}
|
||||
onPress={() => setIsWorkflowSelectorOpen(true)}
|
||||
>
|
||||
{selectedWorkflow?.name || 'Select Version'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Select Simulations */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
|
||||
3
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Select Simulations for the Test Run
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
|
||||
Choose one or more simulations from the table below
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Create Test Run */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
|
||||
4
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Create test run
|
||||
</h3>
|
||||
<Tooltip
|
||||
content={
|
||||
!selectedWorkflow && selectedSimulations.length === 0
|
||||
? "Please select a workflow version and at least one simulation"
|
||||
: !selectedWorkflow
|
||||
? "Please select a workflow version"
|
||||
: selectedSimulations.length === 0
|
||||
? "Please select at least one simulation"
|
||||
: ""
|
||||
}
|
||||
isDisabled={Boolean(selectedWorkflow && selectedSimulations.length > 0)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handleCreateRun}
|
||||
className={(!selectedWorkflow || selectedSimulations.length === 0) ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
Launch Test Run {selectedSimulations.length > 0 ? `(${selectedSimulations.length})` : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display - Only for API/system errors */}
|
||||
{error && error.startsWith('Failed to') && (
|
||||
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulations Table */}
|
||||
{loading ? (
|
||||
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
|
||||
<Spinner size="sm" />
|
||||
Loading simulations...
|
||||
</div>
|
||||
) : simulations.length === 0 ? (
|
||||
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
|
||||
<p className="text-gray-600 dark:text-neutral-400">No simulations created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
items={simulations}
|
||||
columns={columns}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(id) => router.push(`/projects/${projectId}/test/simulations/${id}/edit`)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkflowSelector
|
||||
projectId={projectId}
|
||||
isOpen={isWorkflowSelectorOpen}
|
||||
onOpenChange={setIsWorkflowSelectorOpen}
|
||||
onSelect={setSelectedWorkflow}
|
||||
/>
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulationsApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
|
||||
let selection: "list" | "new" | "edit" = "list";
|
||||
let simulationId: string | undefined;
|
||||
|
||||
if (slug && slug.length > 0) {
|
||||
if (slug[0] === "new") {
|
||||
selection = "new";
|
||||
} else if (slug[1] === "edit") {
|
||||
selection = "edit";
|
||||
simulationId = slug[0];
|
||||
} else {
|
||||
selection = "list";
|
||||
simulationId = slug[0];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{selection === "list" && <SimulationList projectId={projectId} />}
|
||||
{selection === "new" && <NewSimulation projectId={projectId} />}
|
||||
{selection === "edit" && simulationId && (
|
||||
<EditSimulation projectId={projectId} simulationId={simulationId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { StructuredPanel } from "../../../../lib/components/structured-panel";
|
||||
import { ListItem } from "../../../../lib/components/structured-list";
|
||||
|
||||
export function TestingMenu({
|
||||
projectId,
|
||||
app,
|
||||
}: {
|
||||
projectId: string;
|
||||
app: "scenarios" | "simulations" | "profiles" | "runs";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: "Scenarios",
|
||||
href: `/projects/${projectId}/test/scenarios`,
|
||||
isSelected: app === "scenarios"
|
||||
},
|
||||
{
|
||||
label: "Profiles",
|
||||
href: `/projects/${projectId}/test/profiles`,
|
||||
isSelected: app === "profiles"
|
||||
},
|
||||
{
|
||||
label: "Simulations",
|
||||
href: `/projects/${projectId}/test/simulations`,
|
||||
isSelected: app === "simulations"
|
||||
},
|
||||
{
|
||||
label: "Test Runs",
|
||||
href: `/projects/${projectId}/test/runs`,
|
||||
isSelected: app === "runs"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
|
||||
<div className="overflow-auto flex flex-col gap-1 justify-start">
|
||||
{menuItems.map((item) => (
|
||||
<ListItem
|
||||
key={item.label}
|
||||
name={item.label}
|
||||
isSelected={item.isSelected}
|
||||
onClick={() => router.push(item.href)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export const isValidDate = (date: any): boolean => {
|
||||
const parsed = new Date(date);
|
||||
return parsed instanceof Date && !isNaN(parsed.getTime());
|
||||
};
|
||||
|
|
@ -1,20 +1,17 @@
|
|||
"use client";
|
||||
import { MCPServer, WithStringId } from "../../../lib/types/types";
|
||||
import { Workflow } from "../../../lib/types/workflow_types";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { DataSource } from "../../../lib/types/datasource_types";
|
||||
import { z } from "zod";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { WorkflowEditor } from "./workflow_editor";
|
||||
import { WorkflowSelector } from "./workflow_selector";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions";
|
||||
import { listDataSources } from "../../../actions/datasource_actions";
|
||||
import { listMcpServers } from "@/app/actions/mcp_actions";
|
||||
import { collectProjectTools } from "@/app/actions/project_actions";
|
||||
import { collectProjectTools, revertToLiveWorkflow } from "@/app/actions/project_actions";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { getEligibleModels } from "@/app/actions/billing_actions";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
import { Project } from "@/app/lib/types/project_types";
|
||||
|
||||
export function App({
|
||||
projectId,
|
||||
|
|
@ -25,97 +22,54 @@ export function App({
|
|||
useRag: boolean;
|
||||
defaultModel: string;
|
||||
}) {
|
||||
const [selectorKey, setSelectorKey] = useState(0);
|
||||
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
|
||||
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<'draft' | 'live'>('draft');
|
||||
const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | null>(null);
|
||||
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
|
||||
const [projectTools, setProjectTools] = useState<z.infer<typeof WorkflowTool>[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
|
||||
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
|
||||
const [toolWebhookUrl, setToolWebhookUrl] = useState<string>('');
|
||||
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
|
||||
|
||||
const handleSelect = useCallback(async (workflowId: string) => {
|
||||
// choose which workflow to display
|
||||
let workflow: z.infer<typeof Workflow> | undefined = project?.draftWorkflow;
|
||||
if (mode == 'live') {
|
||||
workflow = project?.liveWorkflow;
|
||||
}
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [
|
||||
workflow,
|
||||
publishedWorkflowId,
|
||||
project,
|
||||
dataSources,
|
||||
mcpServers,
|
||||
projectConfig,
|
||||
projectTools,
|
||||
eligibleModels,
|
||||
] = await Promise.all([
|
||||
fetchWorkflow(projectId, workflowId),
|
||||
fetchPublishedWorkflowId(projectId),
|
||||
listDataSources(projectId),
|
||||
listMcpServers(projectId),
|
||||
getProjectConfig(projectId),
|
||||
listDataSources(projectId),
|
||||
collectProjectTools(projectId),
|
||||
getEligibleModels(),
|
||||
]);
|
||||
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setProject(project);
|
||||
setDataSources(dataSources);
|
||||
setMcpServerUrls(mcpServers);
|
||||
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
|
||||
setProjectTools(projectTools);
|
||||
setEligibleModels(eligibleModels);
|
||||
setLoading(false);
|
||||
}, [projectId]);
|
||||
|
||||
function handleShowSelector() {
|
||||
// clear the last workflow id from local storage
|
||||
localStorage.removeItem(`lastWorkflowId_${projectId}`);
|
||||
setAutoSelectIfOnlyOneWorkflow(false);
|
||||
setWorkflow(null);
|
||||
}
|
||||
|
||||
async function handleCreateNewVersion() {
|
||||
setLoading(true);
|
||||
const workflow = await createWorkflow(projectId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listDataSources(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setDataSources(dataSources);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleCloneVersion(workflowId: string) {
|
||||
setLoading(true);
|
||||
const workflow = await cloneWorkflow(projectId, workflowId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listDataSources(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
|
||||
setWorkflow(workflow);
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setDataSources(dataSources);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// whenever workflow becomes null, increment selectorKey
|
||||
useEffect(() => {
|
||||
if (!workflow) {
|
||||
setSelectorKey(s => s + 1);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
// Add this useEffect for initial load
|
||||
useEffect(() => {
|
||||
// Check localStorage first, fall back to lastWorkflowId prop
|
||||
const storedWorkflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
|
||||
if (storedWorkflowId) {
|
||||
handleSelect(storedWorkflowId);
|
||||
}
|
||||
}, [handleSelect, projectId]);
|
||||
loadData();
|
||||
}, [mode, loadData, projectId]);
|
||||
|
||||
function handleSetMode(mode: 'draft' | 'live') {
|
||||
setMode(mode);
|
||||
}
|
||||
|
||||
async function handleRevertToLive() {
|
||||
setLoading(true);
|
||||
await revertToLiveWorkflow(projectId);
|
||||
loadData();
|
||||
}
|
||||
|
||||
// if workflow is null, show the selector
|
||||
// else show workflow editor
|
||||
|
|
@ -124,26 +78,21 @@ export function App({
|
|||
<Spinner size="sm" />
|
||||
<div>Loading workflow...</div>
|
||||
</div>}
|
||||
{!loading && workflow == null && <WorkflowSelector
|
||||
{!loading && !workflow && <div>No workflow found!</div>}
|
||||
{!loading && project && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
|
||||
key={project._id}
|
||||
projectId={projectId}
|
||||
key={selectorKey}
|
||||
handleSelect={handleSelect}
|
||||
handleCreateNewVersion={handleCreateNewVersion}
|
||||
autoSelectIfOnlyOneWorkflow={autoSelectIfOnlyOneWorkflow}
|
||||
/>}
|
||||
{!loading && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
|
||||
key={workflow._id}
|
||||
isLive={mode == 'live'}
|
||||
workflow={workflow}
|
||||
dataSources={dataSources}
|
||||
projectTools={projectTools}
|
||||
publishedWorkflowId={publishedWorkflowId}
|
||||
handleShowSelector={handleShowSelector}
|
||||
handleCloneVersion={handleCloneVersion}
|
||||
useRag={useRag}
|
||||
mcpServerUrls={mcpServerUrls}
|
||||
toolWebhookUrl={toolWebhookUrl}
|
||||
mcpServerUrls={project.mcpServers || []}
|
||||
toolWebhookUrl={project.webhookUrl || ''}
|
||||
defaultModel={defaultModel}
|
||||
eligibleModels={eligibleModels}
|
||||
onChangeMode={handleSetMode}
|
||||
onRevertToLive={handleRevertToLive}
|
||||
/>}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { USE_RAG } from "@/app/lib/feature_flags";
|
|||
import { projectsCollection } from "@/app/lib/mongodb";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { migrate_versioned_workflows } from "@/app/lib/migrate_versioned_workflows";
|
||||
|
||||
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
|
||||
|
||||
|
|
@ -26,6 +27,11 @@ export default async function Page(
|
|||
notFound();
|
||||
}
|
||||
|
||||
// migrate versioned workflows for this project
|
||||
if (!project.draftWorkflow) {
|
||||
await migrate_versioned_workflows(params.projectId);
|
||||
}
|
||||
|
||||
return (
|
||||
<App
|
||||
projectId={params.projectId}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { RadioIcon } from "lucide-react";
|
||||
|
||||
export function PublishedBadge() {
|
||||
return (
|
||||
<div className="bg-green-500/10 rounded-md px-2 py-1 flex items-center gap-1">
|
||||
<RadioIcon size={16} className="text-green-500" />
|
||||
<div className="text-green-500 text-xs font-medium uppercase">Live</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { AgentConfig } from "../entities/agent_config";
|
|||
import { ToolConfig } from "../entities/tool_config";
|
||||
import { App as ChatApp } from "../playground/app";
|
||||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||
import { PromptConfig } from "../entities/prompt_config";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
|
|
@ -20,8 +20,8 @@ import {
|
|||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { Copilot } from "../copilot/app";
|
||||
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
|
||||
import { PublishedBadge } from "./published_badge";
|
||||
import { publishWorkflow } from "@/app/actions/project_actions";
|
||||
import { saveWorkflow } from "@/app/actions/project_actions";
|
||||
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
|
||||
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon } from "lucide-react";
|
||||
import { EntityList } from "./entity_list";
|
||||
|
|
@ -40,8 +40,7 @@ const PANEL_RATIOS = {
|
|||
} as const;
|
||||
|
||||
interface StateItem {
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
publishedWorkflowId: string | null;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
publishing: boolean;
|
||||
selection: {
|
||||
type: "agent" | "tool" | "prompt" | "visualise";
|
||||
|
|
@ -53,6 +52,7 @@ interface StateItem {
|
|||
pendingChanges: boolean;
|
||||
chatKey: number;
|
||||
lastUpdatedAt: string;
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
|
@ -68,9 +68,6 @@ export type Action = {
|
|||
} | {
|
||||
type: "set_publishing";
|
||||
publishing: boolean;
|
||||
} | {
|
||||
type: "set_published_workflow_id";
|
||||
workflowId: string;
|
||||
} | {
|
||||
type: "add_agent";
|
||||
agent: Partial<z.infer<typeof WorkflowAgent>>;
|
||||
|
|
@ -159,7 +156,7 @@ function reducer(state: State, action: Action): State {
|
|||
};
|
||||
}
|
||||
|
||||
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
|
||||
const isLive = state.present.isLive;
|
||||
|
||||
switch (action.type) {
|
||||
case "undo": {
|
||||
|
|
@ -184,24 +181,12 @@ function reducer(state: State, action: Action): State {
|
|||
});
|
||||
break;
|
||||
}
|
||||
case "update_workflow_name": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.workflow.name = action.name;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_publishing": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishing = action.publishing;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_published_workflow_id": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishedWorkflowId = action.workflowId;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set_publish_error": {
|
||||
newState = produce(state, draft => {
|
||||
draft.present.publishError = action.error;
|
||||
|
|
@ -586,29 +571,31 @@ export function useEntitySelection() {
|
|||
}
|
||||
|
||||
export function WorkflowEditor({
|
||||
projectId,
|
||||
dataSources,
|
||||
workflow,
|
||||
publishedWorkflowId,
|
||||
handleShowSelector,
|
||||
handleCloneVersion,
|
||||
useRag,
|
||||
mcpServerUrls,
|
||||
toolWebhookUrl,
|
||||
defaultModel,
|
||||
projectTools,
|
||||
eligibleModels,
|
||||
isLive,
|
||||
onChangeMode,
|
||||
onRevertToLive,
|
||||
}: {
|
||||
projectId: string;
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[];
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
publishedWorkflowId: string | null;
|
||||
handleShowSelector: () => void;
|
||||
handleCloneVersion: (workflowId: string) => void;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
useRag: boolean;
|
||||
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
|
||||
toolWebhookUrl: string;
|
||||
defaultModel: string;
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
eligibleModels: z.infer<typeof ModelsResponse> | "*";
|
||||
isLive: boolean;
|
||||
onChangeMode: (mode: 'draft' | 'live') => void;
|
||||
onRevertToLive: () => void;
|
||||
}) {
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
|
|
@ -619,13 +606,13 @@ export function WorkflowEditor({
|
|||
publishing: false,
|
||||
selection: null,
|
||||
workflow: workflow,
|
||||
publishedWorkflowId: publishedWorkflowId,
|
||||
saving: false,
|
||||
publishError: null,
|
||||
publishSuccess: false,
|
||||
pendingChanges: false,
|
||||
chatKey: 0,
|
||||
lastUpdatedAt: workflow.lastUpdatedAt,
|
||||
isLive,
|
||||
}
|
||||
});
|
||||
const [chatMessages, setChatMessages] = useState<z.infer<typeof Message>[]>([]);
|
||||
|
|
@ -634,17 +621,20 @@ export function WorkflowEditor({
|
|||
}, []);
|
||||
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
|
||||
const saving = useRef(false);
|
||||
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
const [showCopilot, setShowCopilot] = useState(true);
|
||||
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
|
||||
const [isInitialState, setIsInitialState] = useState(true);
|
||||
const [showTour, setShowTour] = useState(true);
|
||||
const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null);
|
||||
|
||||
// Modal state for revert confirmation
|
||||
const { isOpen: isRevertModalOpen, onOpen: onRevertModalOpen, onClose: onRevertModalClose } = useDisclosure();
|
||||
|
||||
// Load agent order from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedOrder = localStorage.getItem(`workflow_${workflow._id}_agent_order`);
|
||||
const mode = isLive ? 'live' : 'draft';
|
||||
const storedOrder = localStorage.getItem(`${mode}_workflow_${projectId}_agent_order`);
|
||||
if (storedOrder) {
|
||||
try {
|
||||
const orderMap = JSON.parse(storedOrder);
|
||||
|
|
@ -660,7 +650,7 @@ export function WorkflowEditor({
|
|||
console.error("Error loading agent order:", e);
|
||||
}
|
||||
}
|
||||
}, [workflow._id, workflow.agents]);
|
||||
}, [workflow.agents, isLive, projectId]);
|
||||
|
||||
// Function to trigger copilot chat
|
||||
const triggerCopilotChat = useCallback((message: string) => {
|
||||
|
|
@ -675,12 +665,12 @@ export function WorkflowEditor({
|
|||
|
||||
// Auto-show copilot and increment key when prompt is present
|
||||
useEffect(() => {
|
||||
const prompt = localStorage.getItem(`project_prompt_${state.present.workflow.projectId}`);
|
||||
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
|
||||
console.log('init project prompt', prompt);
|
||||
if (prompt) {
|
||||
setShowCopilot(true);
|
||||
}
|
||||
}, [state.present.workflow.projectId]);
|
||||
}, [projectId]);
|
||||
|
||||
// Reset initial state when user interacts with copilot or opens other menus
|
||||
useEffect(() => {
|
||||
|
|
@ -788,32 +778,35 @@ export function WorkflowEditor({
|
|||
acc[agent.name] = index;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
localStorage.setItem(`workflow_${workflow._id}_agent_order`, JSON.stringify(orderMap));
|
||||
const mode = isLive ? 'live' : 'draft';
|
||||
localStorage.setItem(`${mode}_workflow_${projectId}_agent_order`, JSON.stringify(orderMap));
|
||||
|
||||
dispatch({ type: "reorder_agents", agents });
|
||||
}
|
||||
|
||||
async function handleRenameWorkflow(name: string) {
|
||||
await renameWorkflow(state.present.workflow.projectId, state.present.workflow._id, name);
|
||||
dispatch({ type: "update_workflow_name", name });
|
||||
async function handlePublishWorkflow() {
|
||||
await publishWorkflow(projectId, state.present.workflow);
|
||||
onChangeMode('live');
|
||||
}
|
||||
|
||||
async function handlePublishWorkflow() {
|
||||
dispatch({ type: "set_publishing", publishing: true });
|
||||
await publishWorkflow(state.present.workflow.projectId, state.present.workflow._id);
|
||||
dispatch({ type: "set_publishing", publishing: false });
|
||||
dispatch({ type: "set_published_workflow_id", workflowId: state.present.workflow._id });
|
||||
function handleRevertToLive() {
|
||||
onRevertModalOpen();
|
||||
}
|
||||
|
||||
function handleConfirmRevert() {
|
||||
onRevertToLive();
|
||||
onRevertModalClose();
|
||||
}
|
||||
|
||||
// Remove handleCopyJSON and add handleDownloadJSON
|
||||
function handleDownloadJSON() {
|
||||
const { _id, projectId, ...workflow } = state.present.workflow;
|
||||
const workflow = state.present.workflow;
|
||||
const json = JSON.stringify(workflow, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${state.present.workflow.name || 'workflow'}.json`;
|
||||
a.download = 'workflow.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
|
@ -831,7 +824,7 @@ export function WorkflowEditor({
|
|||
if (isLive) {
|
||||
return;
|
||||
} else {
|
||||
await saveWorkflow(state.present.workflow.projectId, state.present.workflow._id, workflowToSave);
|
||||
await saveWorkflow(projectId, workflowToSave);
|
||||
}
|
||||
} finally {
|
||||
saving.current = false;
|
||||
|
|
@ -841,7 +834,7 @@ export function WorkflowEditor({
|
|||
dispatch({ type: "set_saving", saving: false });
|
||||
}
|
||||
}
|
||||
}, [isLive]);
|
||||
}, [isLive, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.present.pendingChanges && state.present.workflow) {
|
||||
|
|
@ -869,28 +862,45 @@ export function WorkflowEditor({
|
|||
<div className="shrink-0 flex justify-between items-center pb-6">
|
||||
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
|
||||
<WorkflowIcon size={16} />
|
||||
<Tooltip content="Click to edit">
|
||||
<div>
|
||||
<EditableField
|
||||
key={state.present.workflow._id}
|
||||
value={state.present.workflow?.name || ''}
|
||||
onChange={handleRenameWorkflow}
|
||||
placeholder="Name this version"
|
||||
className="text-sm font-semibold"
|
||||
inline={true}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{state.present.publishing && <Spinner size="sm" />}
|
||||
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
||||
<RadioIcon size={16} />
|
||||
Live
|
||||
Live workflow
|
||||
</div>}
|
||||
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
|
||||
<PenLine size={16} />
|
||||
Draft
|
||||
Draft workflow
|
||||
</div>}
|
||||
{/* Hamburger menu for workflow version switching */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button
|
||||
className="p-1.5 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Workflow version menu"
|
||||
type="button"
|
||||
>
|
||||
<HamburgerIcon size={16} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Workflow version options">
|
||||
<DropdownItem
|
||||
key="switch-version"
|
||||
onClick={() => onChangeMode(isLive ? 'draft' : 'live')}
|
||||
>
|
||||
{isLive ? "View Draft workflow" : "View Live workflow"}
|
||||
</DropdownItem>
|
||||
{!isLive ? (
|
||||
<DropdownItem
|
||||
key="revert-to-live"
|
||||
onClick={handleRevertToLive}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
Revert to Live workflow
|
||||
</DropdownItem>
|
||||
) : null}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{/* Download JSON icon button, with tooltip, to the left of the menu */}
|
||||
<Tooltip content="Download Assistant JSON">
|
||||
<button
|
||||
|
|
@ -902,47 +912,6 @@ export function WorkflowEditor({
|
|||
<DownloadIcon size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<div>
|
||||
<Tooltip content="Version Menu">
|
||||
<button className="p-1.5 text-gray-500 hover:text-gray-800 transition-colors">
|
||||
<HamburgerIcon size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={[
|
||||
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
|
||||
...(isLive ? ['mcp'] : []),
|
||||
]}
|
||||
onAction={(key) => {
|
||||
if (key === 'switch') {
|
||||
handleShowSelector();
|
||||
}
|
||||
if (key === 'clone') {
|
||||
handleCloneVersion(state.present.workflow._id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key="switch"
|
||||
startContent={<div className="text-gray-500"><BackIcon size={16} /></div>}
|
||||
className="gap-x-2"
|
||||
>
|
||||
View versions
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
key="clone"
|
||||
startContent={<div className="text-gray-500"><Layers2Icon size={16} /></div>}
|
||||
className="gap-x-2"
|
||||
>
|
||||
Clone this version
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showCopySuccess && <div className="flex items-center gap-2">
|
||||
|
|
@ -954,15 +923,6 @@ export function WorkflowEditor({
|
|||
<AlertTriangle size={16} />
|
||||
This version is locked. You cannot make changes. Changes applied through copilot will<b>not</b>be reflected.
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
onPress={() => handleCloneVersion(state.present.workflow._id)}
|
||||
className="gap-2 px-4 bg-amber-600 hover:bg-amber-700 text-white font-semibold text-sm"
|
||||
startContent={<Layers2Icon size={16} />}
|
||||
>
|
||||
Clone this version
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
|
|
@ -1041,7 +1001,7 @@ export function WorkflowEditor({
|
|||
onDeleteTool={handleDeleteTool}
|
||||
onDeletePrompt={handleDeletePrompt}
|
||||
onShowVisualise={handleShowVisualise}
|
||||
projectId={state.present.workflow.projectId}
|
||||
projectId={projectId}
|
||||
onReorderAgents={handleReorderAgents}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1055,7 +1015,7 @@ export function WorkflowEditor({
|
|||
<ChatApp
|
||||
key={'' + state.present.chatKey}
|
||||
hidden={state.present.selection !== null}
|
||||
projectId={state.present.workflow.projectId}
|
||||
projectId={projectId}
|
||||
workflow={state.present.workflow}
|
||||
messageSubscriber={updateChatMessages}
|
||||
mcpServerUrls={mcpServerUrls}
|
||||
|
|
@ -1067,7 +1027,7 @@ export function WorkflowEditor({
|
|||
/>
|
||||
{state.present.selection?.type === "agent" && <AgentConfig
|
||||
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}`}
|
||||
projectId={state.present.workflow.projectId}
|
||||
projectId={projectId}
|
||||
workflow={state.present.workflow}
|
||||
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
|
||||
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
|
||||
|
|
@ -1144,7 +1104,7 @@ export function WorkflowEditor({
|
|||
>
|
||||
<Copilot
|
||||
ref={copilotRef}
|
||||
projectId={state.present.workflow.projectId}
|
||||
projectId={projectId}
|
||||
workflow={state.present.workflow}
|
||||
dispatch={dispatch}
|
||||
chatContext={
|
||||
|
|
@ -1169,10 +1129,32 @@ export function WorkflowEditor({
|
|||
</ResizablePanelGroup>
|
||||
{USE_PRODUCT_TOUR && showTour && (
|
||||
<ProductTour
|
||||
projectId={state.present.workflow.projectId}
|
||||
projectId={projectId}
|
||||
onComplete={() => setShowTour(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Revert to Live Confirmation Modal */}
|
||||
<Modal isOpen={isRevertModalOpen} onClose={onRevertModalClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Revert to Live Workflow
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
Are you sure you want to revert to the live workflow? This will discard all your current draft changes and switch back to the live version.
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onRevertModalClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onPress={handleConfirmRevert}>
|
||||
Revert to Live
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</EntitySelectionContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
"use client";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { Workflow } from "../../../lib/types/workflow_types";
|
||||
import { z } from "zod";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { PublishedBadge } from "./published_badge";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { listWorkflows } from "../../../actions/workflow_actions";
|
||||
import { Button, Divider, Pagination } from "@heroui/react";
|
||||
import { WorkflowIcon } from "../../../lib/components/icons";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
const pageSize = 5;
|
||||
|
||||
function WorkflowCard({
|
||||
workflow,
|
||||
live = false,
|
||||
handleSelect,
|
||||
}: {
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
live?: boolean;
|
||||
handleSelect: (workflowId: string) => void;
|
||||
}) {
|
||||
return <button className="flex items-center gap-2 p-2 rounded hover:bg-gray-100 cursor-pointer" onClick={() => handleSelect(workflow._id)}>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex items-center gap-1">
|
||||
<WorkflowIcon />
|
||||
<div className="text-black truncate">{workflow.name || 'Unnamed workflow'}</div>
|
||||
{live && <PublishedBadge />}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function WorkflowSelector({
|
||||
projectId,
|
||||
handleSelect,
|
||||
handleCreateNewVersion,
|
||||
autoSelectIfOnlyOneWorkflow,
|
||||
}: {
|
||||
projectId: string;
|
||||
handleSelect: (workflowId: string) => void;
|
||||
handleCreateNewVersion: () => void;
|
||||
autoSelectIfOnlyOneWorkflow: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workflows, setWorkflows] = useState<(WithStringId<z.infer<typeof Workflow>>)[]>([]);
|
||||
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page);
|
||||
setWorkflows([]);
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
setRetryCount(retryCount + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
async function fetchWorkflows() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { workflows, total, publishedWorkflowId } = await listWorkflows(projectId, currentPage, pageSize);
|
||||
if (ignore) {
|
||||
console.log('ignoring', currentPage);
|
||||
return;
|
||||
}
|
||||
setWorkflows(workflows);
|
||||
setTotalPages(Math.ceil(total / pageSize));
|
||||
setPublishedWorkflowId(publishedWorkflowId);
|
||||
setError(null);
|
||||
|
||||
if (autoSelectIfOnlyOneWorkflow && workflows.length === 1) {
|
||||
handleSelect(workflows[0]._id);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load workflows');
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkflows();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
}
|
||||
}, [projectId, currentPage, retryCount, autoSelectIfOnlyOneWorkflow, handleSelect]);
|
||||
|
||||
return <div className="flex flex-col gap-2 max-w-[768px] mx-auto w-full border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="text-lg">Select a workflow version</div>
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={handleCreateNewVersion}
|
||||
>
|
||||
Create new version
|
||||
</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
{loading && <div className="flex flex-col gap-2">
|
||||
{[...Array(pageSize)].map((_, i) => {
|
||||
const widths = ['w-32', 'w-40', 'w-48', 'w-56'];
|
||||
const randomWidth = widths[Math.floor(Math.random() * widths.length)];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-2 p-2 rounded"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-5 ${randomWidth} bg-gray-200 rounded animate-pulse`}></div>
|
||||
</div>
|
||||
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
{error && <div className="flex flex-col items-center gap-2 text-red-600">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 text-sm bg-red-100 hover:bg-red-200 rounded"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>}
|
||||
{!loading && !error && workflows.length == 0 && <div className="flex flex-col items-center gap-2">
|
||||
<div className="text-sm text-gray-500">No versions found. Create a new version to get started.</div>
|
||||
</div>}
|
||||
{!loading && !error && workflows.length > 0 && <div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{workflows.map((workflow) => (
|
||||
<WorkflowCard
|
||||
key={workflow._id}
|
||||
workflow={workflow}
|
||||
live={publishedWorkflowId == workflow._id}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { useTheme } from "@/app/providers/theme-provider";
|
||||
import { USE_TESTING_FEATURE, USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
|
||||
import { USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
|
||||
import { useHelpModal } from "@/app/providers/help-modal-provider";
|
||||
|
||||
interface SidebarProps {
|
||||
|
|
@ -63,12 +63,6 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
icon: WorkflowIcon,
|
||||
requiresProject: true
|
||||
},
|
||||
...(USE_TESTING_FEATURE ? [{
|
||||
href: 'test',
|
||||
label: 'Test',
|
||||
icon: PlayIcon,
|
||||
requiresProject: true
|
||||
}] : []),
|
||||
...(useRag ? [{
|
||||
href: 'sources',
|
||||
label: 'RAG',
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
let parsed;
|
||||
try {
|
||||
const json = JSON.parse(importJson);
|
||||
parsed = Workflow.omit({ projectId: true }).safeParse(json);
|
||||
parsed = Workflow.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
setImportError('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
|
||||
setImportModalOpen(true);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue