mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-05 12:25:04 -05:00
Compare commits
2 Commits
claude/add
...
swiftyos/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ebef601a | ||
|
|
d919bd5f54 |
@@ -1,174 +0,0 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from autogpt_libs.auth import get_user_id, requires_admin_user
|
||||
from fastapi import APIRouter, Security
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.util.metrics import DiscordChannel, discord_send_alert
|
||||
from backend.util.settings import AppEnvironment, Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class TestDataScriptType(str, Enum):
|
||||
"""Available test data generation scripts."""
|
||||
|
||||
FULL = "full" # test_data_creator.py - creates 100+ users, comprehensive data
|
||||
E2E = "e2e" # e2e_test_data.py - creates 15 users with API functions
|
||||
|
||||
|
||||
class GenerateTestDataRequest(BaseModel):
|
||||
"""Request model for test data generation."""
|
||||
|
||||
script_type: TestDataScriptType = TestDataScriptType.E2E
|
||||
|
||||
|
||||
class GenerateTestDataResponse(BaseModel):
|
||||
"""Response model for test data generation."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
details: dict | None = None
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin", "test-data"],
|
||||
dependencies=[Security(requires_admin_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-test-data",
|
||||
response_model=GenerateTestDataResponse,
|
||||
summary="Generate Test Data",
|
||||
)
|
||||
async def generate_test_data(
|
||||
request: GenerateTestDataRequest,
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
) -> GenerateTestDataResponse:
|
||||
"""
|
||||
Generate test data for the platform.
|
||||
|
||||
This endpoint runs the test data generation scripts to populate the database
|
||||
with sample users, agents, graphs, executions, store listings, and more.
|
||||
|
||||
Available script types:
|
||||
- `e2e`: Creates 15 test users with graphs, library agents, presets, and store submissions.
|
||||
Uses API functions for better compatibility. (Recommended)
|
||||
- `full`: Creates 100+ users with comprehensive test data using direct Prisma calls.
|
||||
Generates more data but may take longer.
|
||||
|
||||
**Warning**: This will add significant data to your database. Use with caution.
|
||||
**Note**: This endpoint is disabled in production environments.
|
||||
"""
|
||||
# Block execution in production environment
|
||||
if settings.config.app_env == AppEnvironment.PRODUCTION:
|
||||
alert_message = (
|
||||
f"🚨 **SECURITY ALERT**: Test data generation attempted in PRODUCTION!\n"
|
||||
f"Admin User ID: `{admin_user_id}`\n"
|
||||
f"Script Type: `{request.script_type}`\n"
|
||||
f"Action: Request was blocked."
|
||||
)
|
||||
logger.warning(
|
||||
f"Test data generation blocked in production. Admin: {admin_user_id}"
|
||||
)
|
||||
|
||||
# Send Discord alert
|
||||
try:
|
||||
await discord_send_alert(alert_message, DiscordChannel.PLATFORM)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord alert: {e}")
|
||||
|
||||
return GenerateTestDataResponse(
|
||||
success=False,
|
||||
message="Test data generation is disabled in production environments.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin user {admin_user_id} is generating test data with script type: {request.script_type}"
|
||||
)
|
||||
|
||||
try:
|
||||
if request.script_type == TestDataScriptType.E2E:
|
||||
# Import and run the E2E test data creator
|
||||
# We need to import within the function to avoid circular imports
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from backend.data.db import prisma
|
||||
|
||||
# Add the test directory to the path
|
||||
test_dir = Path(__file__).parent.parent.parent.parent.parent / "test"
|
||||
sys.path.insert(0, str(test_dir))
|
||||
|
||||
try:
|
||||
from e2e_test_data import ( # pyright: ignore[reportMissingImports]
|
||||
TestDataCreator,
|
||||
)
|
||||
|
||||
# Connect to database if not already connected
|
||||
if not prisma.is_connected():
|
||||
await prisma.connect()
|
||||
|
||||
creator = TestDataCreator()
|
||||
await creator.create_all_test_data()
|
||||
|
||||
return GenerateTestDataResponse(
|
||||
success=True,
|
||||
message="E2E test data generated successfully",
|
||||
details={
|
||||
"users_created": len(creator.users),
|
||||
"graphs_created": len(creator.agent_graphs),
|
||||
"library_agents_created": len(creator.library_agents),
|
||||
"store_submissions_created": len(creator.store_submissions),
|
||||
"presets_created": len(creator.presets),
|
||||
"api_keys_created": len(creator.api_keys),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
# Remove the test directory from the path
|
||||
if str(test_dir) in sys.path:
|
||||
sys.path.remove(str(test_dir))
|
||||
|
||||
elif request.script_type == TestDataScriptType.FULL:
|
||||
# Import and run the full test data creator
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
test_dir = Path(__file__).parent.parent.parent.parent.parent / "test"
|
||||
sys.path.insert(0, str(test_dir))
|
||||
|
||||
try:
|
||||
import test_data_creator # pyright: ignore[reportMissingImports]
|
||||
|
||||
create_full_test_data = test_data_creator.main
|
||||
|
||||
await create_full_test_data()
|
||||
|
||||
return GenerateTestDataResponse(
|
||||
success=True,
|
||||
message="Full test data generated successfully",
|
||||
details={
|
||||
"script": "test_data_creator.py",
|
||||
"note": "Created 100+ users with comprehensive test data",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if str(test_dir) in sys.path:
|
||||
sys.path.remove(str(test_dir))
|
||||
|
||||
else:
|
||||
return GenerateTestDataResponse(
|
||||
success=False,
|
||||
message=f"Unknown script type: {request.script_type}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating test data: {e}")
|
||||
return GenerateTestDataResponse(
|
||||
success=False,
|
||||
message=f"Failed to generate test data: {str(e)}",
|
||||
)
|
||||
@@ -19,7 +19,6 @@ from prisma.errors import PrismaError
|
||||
import backend.api.features.admin.credit_admin_routes
|
||||
import backend.api.features.admin.execution_analytics_routes
|
||||
import backend.api.features.admin.store_admin_routes
|
||||
import backend.api.features.admin.test_data_routes
|
||||
import backend.api.features.builder
|
||||
import backend.api.features.builder.routes
|
||||
import backend.api.features.chat.routes as chat_routes
|
||||
@@ -317,11 +316,6 @@ app.include_router(
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/executions",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.admin.test_data_routes.router,
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/admin",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.executions.review.routes.router,
|
||||
tags=["v2", "executions", "review"],
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||
import {
|
||||
Users,
|
||||
CurrencyDollar,
|
||||
UserFocus,
|
||||
FileText,
|
||||
Database,
|
||||
Faders,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
|
||||
|
||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
const sidebarLinkGroups = [
|
||||
{
|
||||
@@ -14,32 +9,27 @@ const sidebarLinkGroups = [
|
||||
{
|
||||
text: "Marketplace Management",
|
||||
href: "/admin/marketplace",
|
||||
icon: <Users size={24} />,
|
||||
icon: <Users className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "User Spending",
|
||||
href: "/admin/spending",
|
||||
icon: <CurrencyDollar size={24} />,
|
||||
icon: <DollarSign className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "User Impersonation",
|
||||
href: "/admin/impersonation",
|
||||
icon: <UserFocus size={24} />,
|
||||
icon: <UserSearch className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Execution Analytics",
|
||||
href: "/admin/execution-analytics",
|
||||
icon: <FileText size={24} />,
|
||||
icon: <FileText className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Admin User Management",
|
||||
href: "/admin/settings",
|
||||
icon: <Faders size={24} />,
|
||||
},
|
||||
{
|
||||
text: "Test Data",
|
||||
href: "/admin/test-data",
|
||||
icon: <Database size={24} />,
|
||||
icon: <IconSliders className="h-6 w-6" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Select, SelectOption } from "@/components/atoms/Select/Select";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
// Generated types and hooks from OpenAPI spec
|
||||
// Run `npm run generate:api` to regenerate after backend changes
|
||||
import { usePostAdminGenerateTestData } from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
import type { GenerateTestDataResponse } from "@/app/api/__generated__/models/generateTestDataResponse";
|
||||
import type { TestDataScriptType } from "@/app/api/__generated__/models/testDataScriptType";
|
||||
|
||||
const scriptTypeOptions: SelectOption[] = [
|
||||
{
|
||||
value: "e2e",
|
||||
label:
|
||||
"E2E Test Data - 15 users with graphs, agents, and store submissions",
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: "Full Test Data - 100+ users with comprehensive data (takes longer)",
|
||||
},
|
||||
];
|
||||
|
||||
export function GenerateTestDataButton() {
|
||||
const { toast } = useToast();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [scriptType, setScriptType] = useState<TestDataScriptType>("e2e");
|
||||
const [result, setResult] = useState<GenerateTestDataResponse | null>(null);
|
||||
|
||||
const generateMutation = usePostAdminGenerateTestData({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
const data = response.data;
|
||||
setResult(data);
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: data.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: data.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error generating test data:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
setResult({
|
||||
success: false,
|
||||
message: `Failed to generate test data: ${errorMessage}`,
|
||||
});
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate test data. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = () => {
|
||||
setResult(null);
|
||||
generateMutation.mutate({
|
||||
data: {
|
||||
script_type: scriptType,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(true);
|
||||
setResult(null);
|
||||
}}
|
||||
>
|
||||
Generate Test Data
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
title="Generate Test Data"
|
||||
controlled={{
|
||||
isOpen: isDialogOpen,
|
||||
set: (open) => {
|
||||
if (!open) handleDialogClose();
|
||||
},
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Text variant="body" className="pb-4 text-neutral-600">
|
||||
This will populate the database with sample test data including
|
||||
users, agents, graphs, store listings, and more.
|
||||
</Text>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<Select
|
||||
label="Script Type"
|
||||
id="scriptType"
|
||||
value={scriptType}
|
||||
onValueChange={(value) =>
|
||||
setScriptType(value as TestDataScriptType)
|
||||
}
|
||||
disabled={generateMutation.isPending}
|
||||
options={scriptTypeOptions}
|
||||
/>
|
||||
|
||||
<div className="rounded-md bg-yellow-50 p-3 text-yellow-800">
|
||||
<Text variant="small" as="span">
|
||||
<Text variant="small-medium" as="span">
|
||||
Warning:
|
||||
</Text>{" "}
|
||||
This will add significant data to your database. This endpoint
|
||||
is disabled in production environments.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-md p-3 ${
|
||||
result.success
|
||||
? "bg-green-50 text-green-800"
|
||||
: "bg-red-50 text-red-800"
|
||||
}`}
|
||||
>
|
||||
<Text variant="small-medium">{result.message}</Text>
|
||||
{result.details && (
|
||||
<ul className="mt-2 list-inside list-disc">
|
||||
{Object.entries(result.details).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<Text variant="small" as="span">
|
||||
{key.replace(/_/g, " ")}: {String(value)}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDialogClose}
|
||||
disabled={generateMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
loading={generateMutation.isPending}
|
||||
>
|
||||
{generateMutation.isPending
|
||||
? "Generating..."
|
||||
: "Generate Test Data"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
||||
import { GenerateTestDataButton } from "./components/GenerateTestDataButton";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
function TestDataDashboard() {
|
||||
return (
|
||||
<div className="mx-auto p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text variant="h1" className="text-3xl">
|
||||
Test Data Generation
|
||||
</Text>
|
||||
<Text variant="body" className="text-gray-500">
|
||||
Generate sample data for testing and development
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<Text variant="h2" className="mb-4 text-xl">
|
||||
Generate Test Data
|
||||
</Text>
|
||||
<Text variant="body" className="mb-6 text-gray-600">
|
||||
Use this tool to populate the database with sample test data. This
|
||||
is useful for development and testing purposes.
|
||||
</Text>
|
||||
|
||||
<div className="mb-6">
|
||||
<Text variant="body-medium" className="mb-2">
|
||||
Available Script Types:
|
||||
</Text>
|
||||
<ul className="list-inside list-disc space-y-2 text-gray-600">
|
||||
<li>
|
||||
<Text variant="body" as="span">
|
||||
<Text variant="body-medium" as="span">
|
||||
E2E Test Data:
|
||||
</Text>{" "}
|
||||
Creates 15 test users with graphs, library agents, presets,
|
||||
store submissions, and API keys. Uses API functions for better
|
||||
compatibility.
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="body" as="span">
|
||||
<Text variant="body-medium" as="span">
|
||||
Full Test Data:
|
||||
</Text>{" "}
|
||||
Creates 100+ users with comprehensive test data including
|
||||
agent blocks, nodes, executions, analytics, and more. Takes
|
||||
longer to complete.
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<GenerateTestDataButton />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-gray-50 p-6">
|
||||
<Text variant="body-medium" className="mb-2 text-gray-700">
|
||||
What data is created?
|
||||
</Text>
|
||||
<div className="grid gap-4 text-sm text-gray-600 md:grid-cols-2">
|
||||
<div>
|
||||
<Text variant="body-medium">E2E Script:</Text>
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
15 test users
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
15 graphs per user
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Library agents
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Agent presets
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Store submissions
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
API keys
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Creator profiles
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="body-medium">Full Script:</Text>
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
100 users
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
100 agent blocks
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Multiple graphs per user
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Agent nodes and links
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Graph executions
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Store listings and reviews
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Analytics data
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text variant="small" as="span">
|
||||
Credit transactions
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TestDataDashboardPage() {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedTestDataDashboard = await withAdminAccess(TestDataDashboard);
|
||||
return <ProtectedTestDataDashboard />;
|
||||
}
|
||||
@@ -75,47 +75,6 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/admin/generate-test-data": {
|
||||
"post": {
|
||||
"tags": ["v2", "admin"],
|
||||
"summary": "Generate Test Data",
|
||||
"operationId": "postAdminGenerateTestData",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GenerateTestDataRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GenerateTestDataResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/api-keys": {
|
||||
"get": {
|
||||
"tags": ["v1", "api-keys"],
|
||||
@@ -7471,32 +7430,6 @@
|
||||
"required": ["name", "description"],
|
||||
"title": "Graph"
|
||||
},
|
||||
"GenerateTestDataRequest": {
|
||||
"properties": {
|
||||
"script_type": {
|
||||
"allOf": [{ "$ref": "#/components/schemas/TestDataScriptType" }],
|
||||
"default": "e2e"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "GenerateTestDataRequest"
|
||||
},
|
||||
"GenerateTestDataResponse": {
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "title": "Success" },
|
||||
"message": { "type": "string", "title": "Message" },
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{ "type": "object", "additionalProperties": true },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Details"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["success", "message"],
|
||||
"title": "GenerateTestDataResponse"
|
||||
},
|
||||
"GraphExecution": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
@@ -10590,11 +10523,6 @@
|
||||
],
|
||||
"title": "SuggestionsResponse"
|
||||
},
|
||||
"TestDataScriptType": {
|
||||
"type": "string",
|
||||
"enum": ["full", "e2e"],
|
||||
"title": "TestDataScriptType"
|
||||
},
|
||||
"TimezoneResponse": {
|
||||
"properties": {
|
||||
"timezone": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
@@ -31,6 +32,29 @@ export function MessageList({
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
const [showThinkingMessage, setShowThinkingMessage] = useState(false);
|
||||
const [thinkingComplete, setThinkingComplete] = useState(false);
|
||||
|
||||
// Manage thinking message visibility and completion state
|
||||
useEffect(() => {
|
||||
if (isStreaming && streamingChunks.length === 0) {
|
||||
// Start showing thinking message
|
||||
setShowThinkingMessage(true);
|
||||
setThinkingComplete(false);
|
||||
} else if (streamingChunks.length > 0 && showThinkingMessage) {
|
||||
// Chunks arrived - trigger completion animation
|
||||
setThinkingComplete(true);
|
||||
} else if (!isStreaming) {
|
||||
// Streaming ended completely - reset state
|
||||
setShowThinkingMessage(false);
|
||||
setThinkingComplete(false);
|
||||
}
|
||||
}, [isStreaming, streamingChunks.length, showThinkingMessage]);
|
||||
|
||||
function handleThinkingAnimationComplete() {
|
||||
setShowThinkingMessage(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Top fade shadow */}
|
||||
@@ -92,10 +116,15 @@ export function MessageList({
|
||||
})()}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
{showThinkingMessage && (
|
||||
<ThinkingMessage
|
||||
isComplete={thinkingComplete}
|
||||
onAnimationComplete={handleThinkingAnimationComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
{/* Render streaming message if active (wait for thinking animation to complete) */}
|
||||
{isStreaming && streamingChunks.length > 0 && !showThinkingMessage && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
import { Progress } from "@/components/atoms/Progress/Progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { useAsymptoticProgress } from "../ToolCallMessage/useAsymptoticProgress";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
isComplete?: boolean;
|
||||
onAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
export function ThinkingMessage({
|
||||
className,
|
||||
isComplete = false,
|
||||
onAnimationComplete,
|
||||
}: ThinkingMessageProps) {
|
||||
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
||||
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const delayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { progress, isAnimationDone } = useAsymptoticProgress(
|
||||
showCoffeeMessage,
|
||||
isComplete,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current === null) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowSlowLoader(true);
|
||||
}, 8000);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (coffeeTimerRef.current === null) {
|
||||
coffeeTimerRef.current = setTimeout(() => {
|
||||
setShowCoffeeMessage(true);
|
||||
}, 10000);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -37,6 +50,22 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle completion animation delay before unmounting
|
||||
useEffect(() => {
|
||||
if (isAnimationDone && onAnimationComplete) {
|
||||
delayTimerRef.current = setTimeout(() => {
|
||||
onAnimationComplete();
|
||||
}, 200); // 200ms delay after animation completes
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (delayTimerRef.current) {
|
||||
clearTimeout(delayTimerRef.current);
|
||||
delayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAnimationDone, onAnimationComplete]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -49,9 +78,18 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
<AIChatBubble>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showCoffeeMessage ? (
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
This could take a few minutes, grab a coffee ☕️
|
||||
</span>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex w-full max-w-[280px] flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>Working on it...</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2 w-full" />
|
||||
</div>
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
This could take a few minutes, grab a coffee ☕️
|
||||
</span>
|
||||
</div>
|
||||
) : showSlowLoader ? (
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
Taking a bit more time...
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Cubic Ease Out easing function: 1 - (1 - t)^3
|
||||
* Starts fast and decelerates smoothly to a stop.
|
||||
*/
|
||||
function cubicEaseOut(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
export interface AsymptoticProgressResult {
|
||||
progress: number;
|
||||
isAnimationDone: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns a progress value that starts fast and slows down,
|
||||
* asymptotically approaching but never reaching the max value.
|
||||
*
|
||||
* Uses a half-life formula: progress = max * (1 - 0.5^(time/halfLife))
|
||||
* This creates the "game loading bar" effect where:
|
||||
* - 50% is reached at halfLifeSeconds
|
||||
* - 75% is reached at 2 * halfLifeSeconds
|
||||
* - 87.5% is reached at 3 * halfLifeSeconds
|
||||
* - and so on...
|
||||
*
|
||||
* When isComplete is set to true, animates from current progress to 100%
|
||||
* using Cubic Ease Out over 300ms.
|
||||
*
|
||||
* @param isActive - Whether the progress should be animating
|
||||
* @param isComplete - Whether to animate to 100% (completion animation)
|
||||
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
|
||||
* @param maxProgress - Maximum progress value to approach (default: 100)
|
||||
* @param intervalMs - Update interval in milliseconds (default: 100)
|
||||
* @returns Object with current progress value and whether completion animation is done
|
||||
*/
|
||||
export function useAsymptoticProgress(
|
||||
isActive: boolean,
|
||||
isComplete = false,
|
||||
halfLifeSeconds = 30,
|
||||
maxProgress = 100,
|
||||
intervalMs = 100,
|
||||
): AsymptoticProgressResult {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isAnimationDone, setIsAnimationDone] = useState(false);
|
||||
const elapsedTimeRef = useRef(0);
|
||||
const completionStartProgressRef = useRef<number | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
// Handle asymptotic progress when active but not complete
|
||||
useEffect(() => {
|
||||
if (!isActive || isComplete) {
|
||||
if (!isComplete) {
|
||||
setProgress(0);
|
||||
elapsedTimeRef.current = 0;
|
||||
setIsAnimationDone(false);
|
||||
completionStartProgressRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedTimeRef.current += intervalMs / 1000;
|
||||
// Half-life approach: progress = max * (1 - 0.5^(time/halfLife))
|
||||
// At t=halfLife: 50%, at t=2*halfLife: 75%, at t=3*halfLife: 87.5%, etc.
|
||||
const newProgress =
|
||||
maxProgress *
|
||||
(1 - Math.pow(0.5, elapsedTimeRef.current / halfLifeSeconds));
|
||||
setProgress(newProgress);
|
||||
}, intervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive, isComplete, halfLifeSeconds, maxProgress, intervalMs]);
|
||||
|
||||
// Handle completion animation
|
||||
useEffect(() => {
|
||||
if (!isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the starting progress when completion begins
|
||||
if (completionStartProgressRef.current === null) {
|
||||
completionStartProgressRef.current = progress;
|
||||
}
|
||||
|
||||
const startProgress = completionStartProgressRef.current;
|
||||
const animationDuration = 300; // 300ms
|
||||
const startTime = performance.now();
|
||||
|
||||
function animate(currentTime: number) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const t = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
// Cubic Ease Out from current progress to maxProgress
|
||||
const easedProgress =
|
||||
startProgress + (maxProgress - startProgress) * cubicEaseOut(t);
|
||||
setProgress(easedProgress);
|
||||
|
||||
if (t < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setProgress(maxProgress);
|
||||
setIsAnimationDone(true);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isComplete, maxProgress]);
|
||||
|
||||
return { progress, isAnimationDone };
|
||||
}
|
||||
@@ -1136,7 +1136,6 @@ export type AddUserCreditsResponse = {
|
||||
new_balance: number;
|
||||
transaction_key: string;
|
||||
};
|
||||
|
||||
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
|
||||
date: DataType.DATE,
|
||||
time: DataType.TIME,
|
||||
|
||||
Reference in New Issue
Block a user