Merge pull request #27 from rowboatlabs/simulation

Simulation --> Dev || Remove old scenarios code and add simulation runs from DB
This commit is contained in:
Akhilesh Sudhakar 2025-02-17 11:05:41 +05:30 committed by GitHub
commit 1e13d659b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 759 additions and 746 deletions

View file

@ -1,53 +0,0 @@
'use server';
import { ObjectId } from "mongodb";
import { scenariosCollection } from "../lib/mongodb";
import { z } from 'zod';
import { WithStringId } from "../lib/types/types";
import { Scenario } from "../lib/types/testing_types";
import { projectAuthCheck } from "./project_actions";
export async function getScenarios(projectId: string): Promise<WithStringId<z.infer<typeof Scenario>>[]> {
await projectAuthCheck(projectId);
const scenarios = await scenariosCollection.find({ projectId }).toArray();
return scenarios.map(s => ({ ...s, _id: s._id.toString() }));
}
export async function createScenario(projectId: string, name: string, description: string): Promise<string> {
await projectAuthCheck(projectId);
const now = new Date().toISOString();
const result = await scenariosCollection.insertOne({
projectId,
name,
description,
context: '', // Always empty string
lastUpdatedAt: now,
createdAt: now,
});
return result.insertedId.toString();
}
export async function updateScenario(projectId: string, scenarioId: string, name: string, description: string) {
await projectAuthCheck(projectId);
await scenariosCollection.updateOne({
"_id": new ObjectId(scenarioId),
"projectId": projectId,
}, {
$set: {
name,
description,
lastUpdatedAt: new Date().toISOString(),
}
});
}
export async function deleteScenario(projectId: string, scenarioId: string) {
await projectAuthCheck(projectId);
await scenariosCollection.deleteOne({
"_id": new ObjectId(scenarioId),
"projectId": projectId,
});
}

View file

@ -1,11 +1,11 @@
'use server';
import { ObjectId } from "mongodb";
import { scenariosCollection } from "../lib/mongodb";
import { scenariosCollection, simulationRunsCollection, simulationResultsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { projectAuthCheck } from "./project_actions";
import { type WithStringId } from "../lib/types/types";
import { Scenario } from "../lib/types/testing_types";
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../lib/types/testing_types";
import { SimulationScenarioData } from "../lib/types/testing_types";
export async function getScenarios(projectId: string): Promise<WithStringId<z.infer<typeof Scenario>>[]> {
@ -46,6 +46,7 @@ export async function createScenario(projectId: string, name: string, descriptio
name,
description,
context: '',
criteria: '',
lastUpdatedAt: now,
createdAt: now,
});
@ -56,7 +57,12 @@ export async function createScenario(projectId: string, name: string, descriptio
export async function updateScenario(
projectId: string,
scenarioId: string,
updates: { name?: string; description?: string; context?: string }
updates: {
name?: string;
description?: string;
context?: string;
criteria?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
@ -83,4 +89,159 @@ export async function deleteScenario(projectId: string, scenarioId: string): Pro
_id: new ObjectId(scenarioId),
projectId,
});
}
export async function getRuns(projectId: string): Promise<WithStringId<z.infer<typeof SimulationRun>>[]> {
await projectAuthCheck(projectId);
const runs = await simulationRunsCollection
.find({ projectId })
.sort({ startedAt: -1 }) // Most recent first
.toArray();
return runs.map(run => ({
...run,
_id: run._id.toString(),
}));
}
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof SimulationRun>>> {
await projectAuthCheck(projectId);
const run = await simulationRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run) {
throw new Error('Run not found');
}
return {
...run,
_id: run._id.toString(),
};
}
export async function createRun(
projectId: string,
scenarioIds: string[]
): Promise<WithStringId<z.infer<typeof SimulationRun>>> {
await projectAuthCheck(projectId);
const run = {
projectId,
status: 'pending' as const,
scenarioIds,
startedAt: new Date().toISOString(),
};
const result = await simulationRunsCollection.insertOne(run);
return {
...run,
_id: result.insertedId.toString(),
};
}
export async function updateRunStatus(
projectId: string,
runId: string,
status: z.infer<typeof SimulationRun>['status'],
completedAt?: string
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: Partial<z.infer<typeof SimulationRun>> = {
status,
};
if (completedAt) {
updateData.completedAt = completedAt;
}
await simulationRunsCollection.updateOne(
{
_id: new ObjectId(runId),
projectId,
},
{
$set: updateData,
}
);
}
export async function getRunResults(
projectId: string,
runId: string
): Promise<WithStringId<z.infer<typeof SimulationResult>>[]> {
await projectAuthCheck(projectId);
const results = await simulationResultsCollection
.find({
runId,
projectId,
})
.toArray();
return results.map(result => ({
...result,
_id: result._id.toString(),
}));
}
export async function createRunResult(
projectId: string,
runId: string,
scenarioId: string,
result: z.infer<typeof SimulationResult>['result'],
details: string
): Promise<string> {
await projectAuthCheck(projectId);
const resultDoc = {
projectId,
runId,
scenarioId,
result,
details,
};
const insertResult = await simulationResultsCollection.insertOne(resultDoc);
return insertResult.insertedId.toString();
}
export async function createAggregateResult(
projectId: string,
runId: string,
total: number,
pass: number,
fail: number
): Promise<void> {
await projectAuthCheck(projectId);
await simulationRunsCollection.updateOne(
{ _id: new ObjectId(runId), projectId },
{
$set: {
aggregateResults: { total, pass, fail }
}
}
);
}
export async function getAggregateResult(
projectId: string,
runId: string
): Promise<z.infer<typeof SimulationAggregateResult> | null> {
await projectAuthCheck(projectId);
const run = await simulationRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run || !run.aggregateResults) return null;
return run.aggregateResults;
}

View file

@ -7,7 +7,7 @@ import { Project } from "./types/project_types";
import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { Scenario, SimulationResult, SimulationRun } from "./types/testing_types";
import { Scenario, SimulationResult, SimulationRun, SimulationAggregateResult } from "./types/testing_types";
import { z } from 'zod';
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017");

View file

@ -1,42 +1,51 @@
import { z } from "zod";
export const Scenario = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
description: z.string().min(1, "Description cannot be empty"),
criteria: z.string().default(''),
context: z.string().default(''),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});export const SimulationArticleData = z.object({
});
export const SimulationArticleData = z.object({
articleUrl: z.string(),
articleTitle: z.string().default('').optional(),
articleContent: z.string().default('').optional(),
});
export const SimulationScenarioData = z.object({
scenario: z.string(),
context: z.string().default(''),
});
export const SimulationChatMessagesData = z.object({
chatMessages: z.string(),
});
export const SimulationData = z.union([SimulationArticleData, SimulationScenarioData, SimulationChatMessagesData]);
export const SimulationAggregateResult = z.object({
total: z.number(),
pass: z.number(),
fail: z.number(),
});
export const SimulationRun = z.object({
projectId: z.string(),
status: z.union([
z.literal('pending'),
z.literal('running'),
z.literal('completed'),
z.literal('cancelled'),
z.literal('failed')
]),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed']),
scenarioIds: z.array(z.string()),
startedAt: z.string().datetime(),
completedAt: z.string().datetime().optional(),
startedAt: z.string(),
completedAt: z.string().optional(),
aggregateResults: SimulationAggregateResult.optional(),
});
export const SimulationResult = z.object({
projectId: z.string(),
runId: z.string(),
scenarioId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string()
});
});

View file

@ -6,7 +6,6 @@ import { PlaygroundChat } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { SimulationData } from "../../../lib/types/testing_types";
import { SimulationScenarioData } from "../../../lib/types/testing_types";
import { SimulateScenarioOption, SimulateURLOption } from "./simulation-options";
import { Chat } from "./chat";
import { useSearchParams, useRouter } from "next/navigation";
import { ActionButton, Pane } from "../workflow/pane";
@ -36,7 +35,6 @@ export function App({
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
const [loadingChat, setLoadingChat] = useState<boolean>(false);
const [viewSimulationMenu, setViewSimulationMenu] = useState<boolean>(false);
const [counter, setCounter] = useState<number>(0);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
@ -48,7 +46,7 @@ export function App({
const beginSimulation = useCallback((data: z.infer<typeof SimulationData>) => {
setExistingChatId(null);
setViewSimulationMenu(false);
setLoadingChat(true);
setCounter(counter + 1);
setChat({
projectId,
@ -88,7 +86,7 @@ export function App({
function handleNewChatButtonClick() {
setExistingChatId(null);
setViewSimulationMenu(false);
setLoadingChat(true);
setCounter(counter + 1);
setChat({
projectId,
@ -100,7 +98,7 @@ export function App({
}
return (
<Pane title={viewSimulationMenu ? <SimulateLabel /> : "Chat"} actions={[
<Pane title="Chat" actions={[
<ActionButton
key="new-chat"
icon={<MessageSquarePlusIcon size={16} />}
@ -117,10 +115,10 @@ export function App({
</ActionButton>,
]}>
<div className="h-full overflow-auto">
{!viewSimulationMenu && loadingChat && <div className="flex justify-center items-center h-full">
{loadingChat && <div className="flex justify-center items-center h-full">
<Spinner />
</div>}
{!viewSimulationMenu && !loadingChat && <Chat
{!loadingChat && <Chat
key={existingChatId || 'chat-' + counter}
chat={chat}
initialChatId={existingChatId || null}
@ -128,7 +126,6 @@ export function App({
workflow={workflow}
messageSubscriber={messageSubscriber}
/>}
{viewSimulationMenu && <SimulateScenarioOption beginSimulation={beginSimulation} projectId={projectId} />}
</div>
</Pane>
);

View file

@ -1,245 +0,0 @@
'use client';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Spinner, Textarea } from "@nextui-org/react";
import { useState, useEffect } from "react";
import { getScenarios, createScenario, updateScenario, deleteScenario } from "../../../actions/scenario_actions";
import { WithStringId } from "../../../lib/types/types";
import { Scenario } from "../../../lib/types/testing_types";
import { z } from "zod";
import { EditableField } from "../../../lib/components/editable-field";
import { EllipsisVerticalIcon, PlayIcon, PlusIcon } from "lucide-react";
export function AddScenarioForm({
onAdd,
}: {
onAdd: (name: string, description: string) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleAdd = async () => {
if (!name.trim() || !description.trim()) {
setError("Name and description are required");
return;
}
try {
setSaving(true);
await onAdd(name.trim(), description.trim());
setName("");
setDescription("");
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid input");
} finally {
setSaving(false);
}
};
return <div className="flex flex-col gap-2 border rounded-lg p-4 shadow-sm">
<div className="font-semibold text-gray-500">Add scenario</div>
<Input
label="Scenario Name"
labelPlacement="outside"
value={name}
placeholder="Provide a name for the scenario"
size="sm"
variant="bordered"
onChange={(e) => setName(e.target.value)}
isInvalid={!!error}
required
/>
<Textarea
label="Scenario Description"
labelPlacement="outside"
value={description}
placeholder="Describe the test scenario"
size="sm"
variant="bordered"
onChange={(e) => setDescription(e.target.value)}
isInvalid={!!error}
required
/>
{error && <div className="text-red-500 text-sm">{error}</div>}
<Button
onClick={handleAdd}
isLoading={saving}
isDisabled={saving || !name.trim() || !description.trim()}
size="sm"
className="self-start"
variant="bordered"
startContent={
<PlusIcon size={16} />
}
>
Add scenario
</Button>
</div>
}
export function ScenarioList({
projectId,
onPlay,
}: {
projectId: string;
onPlay: (scenario: z.infer<typeof Scenario>) => void;
}) {
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof Scenario> & {
tmp?: boolean;
}>[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [tmpScenarioId, setTmpScenarioId] = useState<number>(0);
const [showAddForm, setShowAddForm] = useState(false);
useEffect(() => {
getScenarios(projectId)
.then(setScenarios)
.finally(() => setLoading(false));
}, [projectId]);
async function handleAddScenario(name: string, description: string) {
try {
const tmpId = 'tmp-' + tmpScenarioId;
setTmpScenarioId(tmpScenarioId + 1);
setSaving(true);
setShowAddForm(false);
setScenarios([...scenarios, {
_id: tmpId,
name,
description,
context: '',
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
tmp: true,
}]);
const id = await createScenario(projectId, name, description);
setScenarios([...scenarios, {
_id: id,
name,
description,
context: '',
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
tmp: false,
}]);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid input");
} finally {
setSaving(false);
}
};
async function handleEditScenario(scenarioId: string, name: string, description: string) {
setSaving(true);
setScenarios(scenarios.map(scenario =>
scenario._id === scenarioId
? { ...scenario, name, description, context: scenario.context }
: scenario
));
await updateScenario(projectId, scenarioId, name, description);
setSaving(false);
}
async function handleDeleteScenario(scenarioId: string) {
setSaving(true);
setScenarios(scenarios.filter(scenario => scenario._id !== scenarioId));
await deleteScenario(projectId, scenarioId);
setSaving(false);
}
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between gap-2 items-center">
<div className="font-semibold text-gray-500">Scenarios</div>
{saving && <div className="flex items-center gap-2">
<Spinner />
<div className="text-sm text-gray-500">Saving...</div>
</div>}
{!showAddForm && <Button
onClick={() => setShowAddForm(true)}
size="sm"
variant="bordered"
>
Add scenario
</Button>}
</div>
{loading && <div className="flex justify-center items-center p-8 gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-500">Loading scenarios...</div>
</div>}
{showAddForm && <AddScenarioForm onAdd={handleAddScenario} />}
{!loading && scenarios.length === 0 && <div className="flex justify-center items-center p-8 gap-2">
<div className="text-sm text-gray-500">No scenarios added</div>
</div>}
{scenarios.length > 0 && <div className="flex flex-col gap-2">
{scenarios.map((scenario) => (
<div key={scenario._id} className="flex flex-col gap-1 rounded-md shadow-sm border border-gray-300">
<div className="flex justify-between items-start">
<div className="grow font-semibold text-lg">
<EditableField
key={'name'}
placeholder="Scenario Name"
value={scenario.name}
onChange={(value) => handleEditScenario(scenario._id, value, scenario.description)}
locked={scenario.tmp}
/>
</div>
<div className="shrink-0 flex items-center mr-2 bg-gray-100 p-1 rounded-b-md">
<button
className="p-1 flex items-center gap-1 text-gray-500 hover:text-blue-500"
onClick={() => onPlay(scenario)}
>
<PlayIcon size={16} />
<div className="text-sm font-semibold">Run</div>
</button>
<Dropdown>
<DropdownTrigger>
<button className="p-1 flex items-center gap-1 text-gray-500 hover:text-gray-700">
<EllipsisVerticalIcon size={16} />
</button>
</DropdownTrigger>
<DropdownMenu
disabledKeys={scenario.tmp ? ['delete'] : ['']}
onAction={(key) => {
if (key === 'delete') {
handleDeleteScenario(scenario._id);
}
}}
>
<DropdownItem
key="delete"
color="danger"
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
<EditableField
key={'description'}
multiline
markdown
light
placeholder="Scenario Description"
value={scenario.description}
onChange={(value) => handleEditScenario(scenario._id, scenario.name, value)}
locked={scenario.tmp}
/>
</div>
))}
</div>}
</div>
);
}

View file

@ -1,108 +0,0 @@
'use client';
import { Input, Textarea } from "@nextui-org/react";
import { FormStatusButton } from "../../../lib/components/FormStatusButton";
import { SimulationData } from "../../../lib/types/testing_types";
import { z } from "zod";
import { scrapeWebpage } from "../../../actions/actions";
import { ScenarioList } from "./scenario-list";
export function SimulateURLOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
function handleUrlSimulationSubmit(formData: FormData) {
const url = formData.get('url') as string;
// fetch article content and title
scrapeWebpage(url).then((result) => {
beginSimulation({
articleUrl: url,
articleContent: result.content,
articleTitle: result.title,
});
});
}
return <form action={handleUrlSimulationSubmit} className="flex flex-col gap-2">
<div>Use a URL / article link:</div>
<input type="hidden" name="projectId" value={projectId} />
<Input
variant="bordered"
placeholder="https://acme.com/articles/product-detiails"
name="url"
required
endContent={<FormStatusButton
props={{
type: "submit",
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>,
children: "Go"
}}
/>}
/>
</form>;
}
export function SimulateScenarioOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
return (
<ScenarioList
projectId={projectId}
onPlay={(scenario) => beginSimulation({
scenario: scenario.description,
context: scenario.context,
})}
/>
);
}
export function SimulateChatContextOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
function handleChatContextSimulationSubmit(formData: FormData) {
beginSimulation({
chatMessages: formData.get('context') as string,
});
}
return <form action={handleChatContextSimulationSubmit} className="flex flex-col gap-2">
<div>Use a previous chat context:</div>
<input type="hidden" name="projectId" value={projectId} />
<Textarea
variant="bordered"
minRows={3}
maxRows={10}
required
name="context"
placeholder={JSON.stringify([
{
"role": "assistant",
"content": "Hello! How can I help you today?"
},
{
"role": "user",
"content": "Hello! I need help with..."
}
], null, 2)}
endContent={<FormStatusButton
props={{
type: "submit",
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>,
children: "Go"
}}
/>}
/>
</form>;
}

View file

@ -1,49 +1,63 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, PencilIcon, XMarkIcon, DocumentDuplicateIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, XMarkIcon, DocumentDuplicateIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { useParams, useRouter } from 'next/navigation';
import {
getScenarios,
createScenario,
updateScenario,
deleteScenario,
getRuns,
getRun,
getRunResults,
createRun,
createRunResult,
updateRunStatus,
createAggregateResult,
getAggregateResult,
} from '../../../actions/simulation_actions';
import { type WithStringId } from '../../../lib/types/types';
import { Scenario } from "../../../lib/types/testing_types";
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
import { z } from 'zod';
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
import { ScenarioViewer } from './components/ScenarioComponents';
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationResult = {
scenarioId: string;
scenarioName: string;
passed: boolean;
details: string;
scenario: ScenarioType;
};
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
type SimulationReport = {
totalScenarios: number;
passedScenarios: number;
failedScenarios: number;
results: SimulationResult[];
results: z.infer<typeof SimulationResult>[];
timestamp: Date;
};
const dummySimulator = async (scenario: ScenarioType): Promise<SimulationResult> => {
const dummySimulator = async (scenario: ScenarioType, runId: string, projectId: string): Promise<z.infer<typeof SimulationResult>> => {
await new Promise(resolve => setTimeout(resolve, 500));
const passed = Math.random() > 0.5;
return {
const result: z.infer<typeof SimulationResult> = {
projectId: projectId,
runId: runId,
scenarioId: scenario._id,
scenarioName: scenario.name,
passed,
result: passed ? 'pass' : 'fail' as const,
details: passed
? "The bot successfully completed the conversation"
: "The bot could not handle the conversation",
scenario: scenario,
};
await createRunResult(
projectId,
runId,
scenario._id,
result.result,
result.details
);
return result;
};
export default function SimulationApp() {
@ -56,6 +70,11 @@ export default function SimulationApp() {
const [isRunning, setIsRunning] = useState(false);
const [simulationReport, setSimulationReport] = useState<SimulationReport | null>(null);
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
const [runs, setRuns] = useState<SimulationRunType[]>([]);
const [activeRun, setActiveRun] = useState<SimulationRunType | null>(null);
const [runResults, setRunResults] = useState<SimulationResultType[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
// Load scenarios on mount
useEffect(() => {
@ -71,6 +90,61 @@ export default function SimulationApp() {
}
}, [menuOpenScenarioId]);
// Modify the fetchRuns function to also fetch results
const fetchRuns = useCallback(async () => {
if (!projectId) return;
setIsLoadingRuns(true);
try {
const runsData = await getRuns(projectId as string);
setRuns(runsData);
// Fetch results for all runs
const resultsPromises = runsData.map(run =>
getRunResults(projectId as string, run._id)
);
const allResults = await Promise.all(resultsPromises);
// Create a map of run ID to results
const resultsMap = runsData.reduce((acc, run, index) => ({
...acc,
[run._id]: allResults[index]
}), {});
setAllRunResults(resultsMap);
} catch (error) {
console.error('Error fetching runs:', error);
} finally {
setIsLoadingRuns(false);
}
}, [projectId]);
// Update the useEffect hooks to include fetchRuns
useEffect(() => {
if (!projectId) return;
fetchRuns();
}, [projectId, fetchRuns]);
useEffect(() => {
if (!projectId || !activeRun || activeRun.status === 'completed' || activeRun.status === 'cancelled') return;
const interval = setInterval(async () => {
try {
const updatedRun = await getRun(projectId as string, activeRun._id);
setActiveRun(updatedRun);
if (updatedRun.status === 'completed') {
const results = await getRunResults(projectId as string, activeRun._id);
setRunResults(results);
fetchRuns(); // Refresh the runs list
}
} catch (error) {
console.error('Error polling run status:', error);
}
}, 2000);
return () => clearInterval(interval);
}, [activeRun, projectId, fetchRuns]);
const createNewScenario = async () => {
if (!projectId) return;
const newScenarioId = await createScenario(
@ -96,6 +170,7 @@ export default function SimulationApp() {
{
name: updatedScenario.name,
description: updatedScenario.description,
criteria: updatedScenario.criteria,
context: updatedScenario.context,
}
);
@ -127,31 +202,61 @@ export default function SimulationApp() {
};
const runAllScenarios = async () => {
if (!projectId) return;
setIsRunning(true);
setSimulationReport(null);
try {
const results: SimulationResult[] = [];
const newRun = await createRun(
projectId as string,
scenarios.map(s => s._id)
);
setActiveRun(newRun);
const shouldMock = process.env.NEXT_PUBLIC_MOCK_SIMULATION_RESULTS === 'true';
// Run each scenario through the simulator
for (const scenario of scenarios) {
const result = await dummySimulator(scenario);
results.push(result);
if (shouldMock) {
console.log('Using mock simulation...');
await updateRunStatus(projectId as string, newRun._id, 'running');
// Run all scenarios and collect results
const mockResults = await Promise.all(
scenarios.map(scenario =>
dummySimulator(scenario, newRun._id, projectId as string)
)
);
// Calculate and store aggregate results before marking as complete
const total = scenarios.length;
const pass = mockResults.filter(r => r.result === 'pass').length;
const fail = mockResults.filter(r => r.result === 'fail').length;
await createAggregateResult(
projectId as string,
newRun._id,
total,
pass,
fail
);
await updateRunStatus(
projectId as string,
newRun._id,
'completed',
new Date().toISOString()
);
const results = await getRunResults(projectId as string, newRun._id);
setRunResults(results);
const updatedRun = await getRun(projectId as string, newRun._id);
setActiveRun(updatedRun);
}
// Generate report
const passedScenarios = results.filter(r => r.passed).length;
const report: SimulationReport = {
totalScenarios: scenarios.length,
passedScenarios,
failedScenarios: scenarios.length - passedScenarios,
results,
timestamp: new Date(),
};
setSimulationReport(report);
await fetchRuns();
} catch (error) {
console.error('Error running scenarios:', error);
console.error('Error starting scenarios:', error);
} finally {
setIsRunning(false);
}
@ -243,154 +348,43 @@ export default function SimulationApp() {
{/* Main content */}
<div className="flex-1 p-6 overflow-auto">
{selectedScenario ? (
isEditing ? (
<ScenarioEditor
scenario={selectedScenario}
onSave={handleUpdateScenario}
onCancel={() => setIsEditing(false)}
/>
) : (
<ScenarioViewer
scenario={selectedScenario}
onEdit={() => setIsEditing(true)}
onClose={handleCloseScenario}
/>
)
<ScenarioViewer
scenario={selectedScenario}
onSave={handleUpdateScenario}
onClose={handleCloseScenario}
/>
) : (
<div className="space-y-6">
{simulationReport ? (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">Simulation Results</h1>
<div className="text-sm text-gray-500 mt-1">
Run on {simulationReport.timestamp.toLocaleString()}
</div>
</div>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
</div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Simulation Runs</h1>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-gray-500">Total Scenarios</div>
<div className="text-2xl font-bold">{simulationReport.totalScenarios}</div>
</div>
<div className="bg-green-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-green-600">Passed</div>
<div className="text-2xl font-bold text-green-600">
{simulationReport.passedScenarios}
</div>
</div>
<div className="bg-red-50 p-4 rounded-lg min-h-[100px] flex flex-col justify-center">
<div className="text-sm text-red-600">Failed</div>
<div className="text-2xl font-bold text-red-600">
{simulationReport.failedScenarios}
</div>
</div>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Detailed Results</h2>
<div className="space-y-2">
{simulationReport.results.map((result) => (
<div
key={result.scenarioId}
className={`p-4 rounded-lg border ${
result.passed ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => {
const newExpandedResults = new Set(expandedResults);
if (expandedResults.has(result.scenarioId)) {
newExpandedResults.delete(result.scenarioId);
} else {
newExpandedResults.add(result.scenarioId);
}
setExpandedResults(newExpandedResults);
}}
>
<div className="font-medium flex items-center gap-2">
<ChevronRightIcon
className={`h-5 w-5 transform transition-transform ${
expandedResults.has(result.scenarioId) ? 'rotate-90' : ''
}`}
/>
{result.scenarioName}
</div>
<div
className={`px-2 py-1 rounded text-sm w-16 text-center ${
result.passed
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{result.passed ? 'Passed' : 'Failed'}
</div>
</div>
{expandedResults.has(result.scenarioId) && (
<div className="mt-4 pl-7 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Name
</div>
<div className="text-sm">{result.scenario.name}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Description
</div>
<div className="text-sm">{result.scenario.description}</div>
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-1">
Details
</div>
<div className="text-sm">{result.details}</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
</>
{isLoadingRuns ? (
<div className="text-center py-4">Loading runs...</div>
) : runs.length === 0 ? (
<div className="text-center py-4 text-gray-500">No simulation runs yet</div>
) : (
<>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Scenarios</h1>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
<div className="space-y-4">
{runs.map((run) => (
<SimulationResultCard
key={run._id}
run={run}
results={allRunResults[run._id] || []}
scenarios={scenarios}
/>
))}
</div>
<div className="text-center text-gray-500 mt-10">
Select a scenario or run all scenarios
</div>
</>
)}
</div>
)}
@ -398,140 +392,3 @@ export default function SimulationApp() {
</div>
);
}
function ScenarioViewer({
scenario,
onEdit,
onClose,
}: {
scenario: ScenarioType;
onEdit: () => void;
onClose: () => void;
}) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">{scenario.name}</h1>
<div className="flex gap-2">
<button
onClick={onEdit}
className="p-2 rounded-full hover:bg-gray-100"
title="Edit"
>
<PencilIcon className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<div className="text-base">{scenario.name}</div>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<div className="text-base whitespace-pre-wrap">{scenario.description}</div>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CONTEXT</div>
<div className="text-base whitespace-pre-wrap">{scenario.context}</div>
</div>
</div>
</div>
);
}
function ScenarioEditor({
scenario,
onSave,
onCancel,
}: {
scenario: ScenarioType;
onSave: (scenario: ScenarioType) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(scenario.name);
const [description, setDescription] = useState(scenario.description);
const [context, setContext] = useState(scenario.context || '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...scenario,
name,
description,
context,
});
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Edit Scenario</h1>
<div className="flex gap-2">
<button
onClick={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
className="p-2 rounded-full hover:bg-gray-100"
title="Save"
>
<DocumentDuplicateIcon className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 px-3 py-2"
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="mt-1 block w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 px-3 py-2"
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CONTEXT</div>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
className="mt-1 block w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 px-3 py-2"
/>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,262 @@
'use client';
import { useState, useEffect } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { WithStringId } from '../../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types";
import { z } from 'zod';
import { getAggregateResult } from '../../../../actions/simulation_actions';
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
interface SimulationResultCardProps {
run: SimulationRunType;
results: SimulationResultType[];
scenarios: ScenarioType[];
}
export const SimulationResultCard = ({ run, results, scenarios }: SimulationResultCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [expandedScenarios, setExpandedScenarios] = useState<Set<string>>(new Set());
// Replace the manual calculations with aggregate results from the run object
const totalScenarios = run.aggregateResults?.total ?? run.scenarioIds.length;
const passedScenarios = run.aggregateResults?.pass ?? 0;
const failedScenarios = run.aggregateResults?.fail ?? 0;
const statusLabelClass = "px-3 py-1 rounded text-xs min-w-[60px] text-center uppercase font-semibold";
const formatMainTitle = (date: string) => {
return `Run from ${new Date(date).toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
})}`;
};
const formatDateTime = (date: string) => {
return new Date(date).toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
});
};
const getDuration = () => {
if (!run.completedAt) return 'In Progress';
const start = new Date(run.startedAt);
const end = new Date(run.completedAt);
const diff = end.getTime() - start.getTime();
return `${(diff / 1000).toFixed(1)}s`;
};
const toggleScenario = (scenarioId: string, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering parent's onClick
setExpandedScenarios(prev => {
const newSet = new Set(prev);
if (newSet.has(scenarioId)) {
newSet.delete(scenarioId);
} else {
newSet.add(scenarioId);
}
return newSet;
});
};
return (
<div className="border rounded-lg mb-4 shadow-sm">
<div
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2">
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
) : (
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
)}
<div className="text-lg font-semibold">
{formatMainTitle(run.startedAt)}
</div>
</div>
<span className={`${statusLabelClass} ${
run.status === 'completed' ? 'bg-green-100 text-green-800' :
run.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{run.status}
</span>
</div>
{isExpanded && (
<div className="p-4 border-t">
{/* Simplified timing information */}
<div className="mb-6 text-sm text-gray-500 space-y-1">
<div className="flex items-center">
<span className="w-24 text-gray-600">Completed:</span>
<span>{run.completedAt ? formatDateTime(run.completedAt) : 'Not completed'}</span>
</div>
<div className="flex items-center">
<span className="w-24 text-gray-600">Duration:</span>
<span>{getDuration()}</span>
</div>
</div>
{/* Results statistics */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-gray-50">
<div className="text-sm text-gray-600">Total Scenarios</div>
<div className="text-2xl font-semibold">{totalScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-green-50">
<div className="text-sm text-green-600">Passed</div>
<div className="text-2xl font-semibold text-green-700">{passedScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-red-50">
<div className="text-sm text-red-600">Failed</div>
<div className="text-2xl font-semibold text-red-700">{failedScenarios}</div>
</div>
</div>
<div className="space-y-2">
{run.scenarioIds.map(scenarioId => {
const scenario = scenarios.find(s => s._id === scenarioId);
const result = results.find(r => r.scenarioId === scenarioId);
const isScenarioExpanded = expandedScenarios.has(scenarioId);
return scenario && (
<div
key={scenarioId}
className={`border rounded-lg overflow-hidden ${
result?.result === 'pass' ? 'bg-green-50 border-green-200' :
result?.result === 'fail' ? 'bg-red-50 border-red-200' :
'bg-gray-50 border-gray-200'
}`}
>
<div
className="p-3 flex items-center justify-between cursor-pointer hover:bg-opacity-80"
onClick={(e) => toggleScenario(scenarioId, e)}
>
<div className="flex items-center space-x-2">
{isScenarioExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-600" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-600" />
)}
<span className="font-medium text-gray-900">{scenario.name}</span>
</div>
{result && (
<span className={`${statusLabelClass} ${
result.result === 'pass' ? 'bg-green-200 text-green-900' : 'bg-red-200 text-red-900'
}`}>
{result.result}
</span>
)}
</div>
{isScenarioExpanded && (
<div className="p-3 border-t border-opacity-50 space-y-4">
<div>
<div className="text-sm font-medium mb-1">Description</div>
<div className="text-sm text-gray-700">
{scenario.description}
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Criteria</div>
<div className="text-sm text-gray-700">
{scenario.criteria || 'No criteria specified'}
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Context</div>
<div className="text-sm text-gray-700">
{scenario.context || 'No context provided'}
</div>
</div>
{result && (
<div>
<div className="text-sm font-medium mb-1">Result Details</div>
<div className="text-sm text-gray-700">
{result.details}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
};
interface ScenarioResultCardProps {
scenario: ScenarioType;
result?: SimulationResultType;
}
export const ScenarioResultCard = ({ scenario, result }: ScenarioResultCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="border rounded-lg mb-2 last:mb-0">
<div
className="p-3 flex items-center justify-between cursor-pointer hover:bg-gray-50"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-400" />
)}
<span className="font-medium">{scenario.name}</span>
</div>
{result && (
<span className={`px-2 py-1 rounded-full text-xs ${
result.result === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{result.result}
</span>
)}
</div>
{isExpanded && (
<div className="p-3 border-t space-y-2 bg-gray-50">
<div>
<div className="text-sm font-medium text-gray-600">Description</div>
<div className="text-sm">{scenario.description}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-600">Criteria</div>
<div className="text-sm">{scenario.criteria}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-600">Context</div>
<div className="text-sm">{scenario.context}</div>
</div>
{result && (
<div>
<div className="text-sm font-medium text-gray-600">Result Details</div>
<div className="text-sm">{result.details}</div>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,133 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { WithStringId } from '../../../../lib/types/types';
import { Scenario } from "../../../../lib/types/testing_types";
import { z } from 'zod';
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
interface ScenarioViewerProps {
scenario: ScenarioType;
onSave: (scenario: ScenarioType) => void;
onClose: () => void;
}
export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProps) {
const [editedScenario, setEditedScenario] = useState<ScenarioType>(scenario);
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
// Reset state when scenario changes
useEffect(() => {
setEditedScenario(scenario);
}, [scenario]);
const handleChange = useCallback((field: keyof ScenarioType, value: string) => {
setEditedScenario(prev => ({
...prev,
[field]: value,
}));
// Clear existing timeout
if (saveTimeout) {
clearTimeout(saveTimeout);
}
// Set new timeout
const timeoutId = setTimeout(() => {
onSave({
...editedScenario,
[field]: value,
});
}, 500);
setSaveTimeout(timeoutId);
}, [editedScenario, onSave, saveTimeout]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
};
}, [saveTimeout]);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Scenario Details</h1>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<input
type="text"
value={editedScenario.name}
onChange={(e) => handleChange('name', e.target.value)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<textarea
value={editedScenario.description}
onChange={(e) => handleChange('description', e.target.value)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CRITERIA</div>
<textarea
value={editedScenario.criteria}
onChange={(e) => handleChange('criteria', e.target.value)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CONTEXT</div>
<textarea
value={editedScenario.context}
onChange={(e) => handleChange('context', e.target.value)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
</div>
</div>
</div>
);
}

View file

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

View file

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