Merge branch 'dev' into swiftyos/vector-search

This commit is contained in:
Swifty
2025-12-09 08:55:07 +01:00
committed by GitHub
50 changed files with 2135 additions and 926 deletions

View File

@@ -1,12 +1,45 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import prisma.types
from pydantic import BaseModel
from backend.data.db import query_raw_with_schema
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
class AccuracyAlertData(BaseModel):
"""Alert data when accuracy drops significantly."""
graph_id: str
user_id: Optional[str]
drop_percent: float
three_day_avg: float
seven_day_avg: float
detected_at: datetime
class AccuracyLatestData(BaseModel):
"""Latest execution accuracy data point."""
date: datetime
daily_score: Optional[float]
three_day_avg: Optional[float]
seven_day_avg: Optional[float]
fourteen_day_avg: Optional[float]
class AccuracyTrendsResponse(BaseModel):
"""Response model for accuracy trends and alerts."""
latest_data: AccuracyLatestData
alert: Optional[AccuracyAlertData]
historical_data: Optional[list[AccuracyLatestData]] = None
async def log_raw_analytics(
user_id: str,
type: str,
@@ -43,3 +76,217 @@ async def log_raw_metric(
)
return result
async def get_accuracy_trends_and_alerts(
graph_id: str,
days_back: int = 30,
user_id: Optional[str] = None,
drop_threshold: float = 10.0,
include_historical: bool = False,
) -> AccuracyTrendsResponse:
"""Get accuracy trends and detect alerts for a specific graph."""
query_template = """
WITH daily_scores AS (
SELECT
DATE(e."createdAt") as execution_date,
AVG(CASE
WHEN e.stats IS NOT NULL
AND e.stats::json->>'correctness_score' IS NOT NULL
AND e.stats::json->>'correctness_score' != 'null'
THEN (e.stats::json->>'correctness_score')::float * 100
ELSE NULL
END) as daily_score
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."agentGraphId" = $1::text
AND e."isDeleted" = false
AND e."createdAt" >= $2::timestamp
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
{user_filter}
GROUP BY DATE(e."createdAt")
HAVING COUNT(*) >= 3 -- Need at least 3 executions per day
),
trends AS (
SELECT
execution_date,
daily_score,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as three_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as seven_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW
) as fourteen_day_avg
FROM daily_scores
)
SELECT *,
CASE
WHEN three_day_avg IS NOT NULL AND seven_day_avg IS NOT NULL AND seven_day_avg > 0
THEN ((seven_day_avg - three_day_avg) / seven_day_avg * 100)
ELSE NULL
END as drop_percent
FROM trends
ORDER BY execution_date DESC
{limit_clause}
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
params = [graph_id, start_date]
user_filter = ""
if user_id:
user_filter = 'AND e."userId" = $3::text'
params.append(user_id)
# Determine limit clause
limit_clause = "" if include_historical else "LIMIT 1"
final_query = query_template.format(
schema_prefix="{schema_prefix}",
user_filter=user_filter,
limit_clause=limit_clause,
)
result = await query_raw_with_schema(final_query, *params)
if not result:
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=datetime.now(timezone.utc),
daily_score=None,
three_day_avg=None,
seven_day_avg=None,
fourteen_day_avg=None,
),
alert=None,
)
latest = result[0]
alert = None
if (
latest["drop_percent"] is not None
and latest["drop_percent"] >= drop_threshold
and latest["three_day_avg"] is not None
and latest["seven_day_avg"] is not None
):
alert = AccuracyAlertData(
graph_id=graph_id,
user_id=user_id,
drop_percent=float(latest["drop_percent"]),
three_day_avg=float(latest["three_day_avg"]),
seven_day_avg=float(latest["seven_day_avg"]),
detected_at=datetime.now(timezone.utc),
)
# Prepare historical data if requested
historical_data = None
if include_historical:
historical_data = []
for row in result:
historical_data.append(
AccuracyLatestData(
date=row["execution_date"],
daily_score=(
float(row["daily_score"])
if row["daily_score"] is not None
else None
),
three_day_avg=(
float(row["three_day_avg"])
if row["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(row["seven_day_avg"])
if row["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(row["fourteen_day_avg"])
if row["fourteen_day_avg"] is not None
else None
),
)
)
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=latest["execution_date"],
daily_score=(
float(latest["daily_score"])
if latest["daily_score"] is not None
else None
),
three_day_avg=(
float(latest["three_day_avg"])
if latest["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(latest["seven_day_avg"])
if latest["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(latest["fourteen_day_avg"])
if latest["fourteen_day_avg"] is not None
else None
),
),
alert=alert,
historical_data=historical_data,
)
class MarketplaceGraphData(BaseModel):
"""Data structure for marketplace graph monitoring."""
graph_id: str
user_id: Optional[str]
execution_count: int
async def get_marketplace_graphs_for_monitoring(
days_back: int = 30,
min_executions: int = 10,
) -> list[MarketplaceGraphData]:
"""Get published marketplace graphs with recent executions for monitoring."""
query_template = """
WITH marketplace_graphs AS (
SELECT DISTINCT
slv."agentGraphId" as graph_id,
slv."agentGraphVersion" as graph_version
FROM {schema_prefix}"StoreListing" sl
JOIN {schema_prefix}"StoreListingVersion" slv ON sl."activeVersionId" = slv."id"
WHERE sl."hasApprovedVersion" = true
AND sl."isDeleted" = false
)
SELECT DISTINCT
mg.graph_id,
NULL as user_id, -- Marketplace graphs don't have a specific user_id for monitoring
COUNT(*) as execution_count
FROM marketplace_graphs mg
JOIN {schema_prefix}"AgentGraphExecution" e ON e."agentGraphId" = mg.graph_id
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY mg.graph_id
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
MarketplaceGraphData(
graph_id=row["graph_id"],
user_id=row["user_id"],
execution_count=int(row["execution_count"]),
)
for row in result
]

View File

@@ -1465,3 +1465,35 @@ async def get_graph_execution_by_share_token(
created_at=execution.createdAt,
outputs=outputs,
)
async def get_frequently_executed_graphs(
days_back: int = 30,
min_executions: int = 10,
) -> list[dict]:
"""Get graphs that have been frequently executed for monitoring."""
query_template = """
SELECT DISTINCT
e."agentGraphId" as graph_id,
e."userId" as user_id,
COUNT(*) as execution_count
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY e."agentGraphId", e."userId"
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
{
"graph_id": row["graph_id"],
"user_id": row["user_id"],
"execution_count": int(row["execution_count"]),
}
for row in result
]

View File

@@ -3,12 +3,17 @@ from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Callable, Concatenate, ParamSpec, TypeVar, cast
from backend.data import db
from backend.data.analytics import (
get_accuracy_trends_and_alerts,
get_marketplace_graphs_for_monitoring,
)
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
from backend.data.execution import (
create_graph_execution,
get_block_error_stats,
get_child_graph_executions,
get_execution_kv_data,
get_frequently_executed_graphs,
get_graph_execution_meta,
get_graph_executions,
get_graph_executions_count,
@@ -145,6 +150,9 @@ class DatabaseManager(AppService):
get_execution_kv_data = _(get_execution_kv_data)
set_execution_kv_data = _(set_execution_kv_data)
get_block_error_stats = _(get_block_error_stats)
get_accuracy_trends_and_alerts = _(get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(get_marketplace_graphs_for_monitoring)
# Graphs
get_node = _(get_node)
@@ -226,6 +234,10 @@ class DatabaseManagerClient(AppServiceClient):
# Block error monitoring
get_block_error_stats = _(d.get_block_error_stats)
# Execution accuracy monitoring
get_accuracy_trends_and_alerts = _(d.get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(d.get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(d.get_marketplace_graphs_for_monitoring)
# Human In The Loop
has_pending_reviews_for_graph_exec = _(d.has_pending_reviews_for_graph_exec)

View File

@@ -33,6 +33,7 @@ from backend.monitoring import (
process_existing_batches,
process_weekly_summary,
report_block_error_rates,
report_execution_accuracy_alerts,
report_late_executions,
)
from backend.util.clients import get_scheduler_client
@@ -241,6 +242,11 @@ def cleanup_expired_files():
run_async(cleanup_expired_files_async())
def execution_accuracy_alerts():
"""Check execution accuracy and send alerts if drops are detected."""
return report_execution_accuracy_alerts()
# Monitoring functions are now imported from monitoring module
@@ -440,6 +446,17 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
# Execution Accuracy Monitoring - configurable interval
self.scheduler.add_job(
execution_accuracy_alerts,
id="report_execution_accuracy_alerts",
trigger="interval",
replace_existing=True,
seconds=config.execution_accuracy_check_interval_hours
* 3600, # Convert hours to seconds
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED)
self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES)
@@ -587,6 +604,11 @@ class Scheduler(AppService):
"""Manually trigger cleanup of expired cloud storage files."""
return cleanup_expired_files()
@expose
def execute_report_execution_accuracy_alerts(self):
"""Manually trigger execution accuracy alert checking."""
return execution_accuracy_alerts()
class SchedulerClient(AppServiceClient):
@classmethod

View File

@@ -1,5 +1,6 @@
"""Monitoring module for platform health and alerting."""
from .accuracy_monitor import AccuracyMonitor, report_execution_accuracy_alerts
from .block_error_monitor import BlockErrorMonitor, report_block_error_rates
from .late_execution_monitor import (
LateExecutionException,
@@ -13,10 +14,12 @@ from .notification_monitor import (
)
__all__ = [
"AccuracyMonitor",
"BlockErrorMonitor",
"LateExecutionMonitor",
"LateExecutionException",
"NotificationJobArgs",
"report_execution_accuracy_alerts",
"report_block_error_rates",
"report_late_executions",
"process_existing_batches",

View File

@@ -0,0 +1,107 @@
"""Execution accuracy monitoring module."""
import logging
from backend.util.clients import (
get_database_manager_client,
get_notification_manager_client,
)
from backend.util.metrics import DiscordChannel, sentry_capture_error
from backend.util.settings import Config
logger = logging.getLogger(__name__)
config = Config()
class AccuracyMonitor:
"""Monitor execution accuracy trends and send alerts for drops."""
def __init__(self, drop_threshold: float = 10.0):
self.config = config
self.notification_client = get_notification_manager_client()
self.database_client = get_database_manager_client()
self.drop_threshold = drop_threshold
def check_execution_accuracy_alerts(self) -> str:
"""Check marketplace agents for accuracy drops and send alerts."""
try:
logger.info("Checking execution accuracy for marketplace agents")
# Get marketplace graphs using database client
graphs = self.database_client.get_marketplace_graphs_for_monitoring(
days_back=30, min_executions=10
)
alerts_found = 0
for graph_data in graphs:
result = self.database_client.get_accuracy_trends_and_alerts(
graph_id=graph_data.graph_id,
user_id=graph_data.user_id,
days_back=21, # 3 weeks
drop_threshold=self.drop_threshold,
)
if result.alert:
alert = result.alert
# Get graph details for better alert info
try:
graph_info = self.database_client.get_graph_metadata(
graph_id=alert.graph_id
)
graph_name = graph_info.name if graph_info else "Unknown Agent"
except Exception:
graph_name = "Unknown Agent"
# Create detailed alert message
alert_msg = (
f"🚨 **AGENT ACCURACY DROP DETECTED**\n\n"
f"**Agent:** {graph_name}\n"
f"**Graph ID:** `{alert.graph_id}`\n"
f"**Accuracy Drop:** {alert.drop_percent:.1f}%\n"
f"**Recent Performance:**\n"
f" • 3-day average: {alert.three_day_avg:.1f}%\n"
f" • 7-day average: {alert.seven_day_avg:.1f}%\n"
)
if alert.user_id:
alert_msg += f"**Owner:** {alert.user_id}\n"
# Send individual alert for each agent (not batched)
self.notification_client.discord_system_alert(
alert_msg, DiscordChannel.PRODUCT
)
alerts_found += 1
logger.warning(
f"Sent accuracy alert for agent: {graph_name} ({alert.graph_id})"
)
if alerts_found > 0:
return f"Alert sent for {alerts_found} agents with accuracy drops"
logger.info("No execution accuracy alerts detected")
return "No accuracy alerts detected"
except Exception as e:
logger.exception(f"Error checking execution accuracy alerts: {e}")
error = Exception(f"Error checking execution accuracy alerts: {e}")
msg = str(error)
sentry_capture_error(error)
self.notification_client.discord_system_alert(msg, DiscordChannel.PRODUCT)
return msg
def report_execution_accuracy_alerts(drop_threshold: float = 10.0) -> str:
"""
Check execution accuracy and send alerts if drops are detected.
Args:
drop_threshold: Percentage drop threshold to trigger alerts (default 10.0%)
Returns:
Status message indicating results of the check
"""
monitor = AccuracyMonitor(drop_threshold=drop_threshold)
return monitor.check_execution_accuracy_alerts()

View File

@@ -8,6 +8,10 @@ from fastapi import APIRouter, HTTPException, Security
from pydantic import BaseModel, Field
from backend.blocks.llm import LlmModel
from backend.data.analytics import (
AccuracyTrendsResponse,
get_accuracy_trends_and_alerts,
)
from backend.data.execution import (
ExecutionStatus,
GraphExecutionMeta,
@@ -83,6 +87,18 @@ class ExecutionAnalyticsConfig(BaseModel):
recommended_model: str
class AccuracyTrendsRequest(BaseModel):
graph_id: str = Field(..., description="Graph ID to analyze", min_length=1)
user_id: Optional[str] = Field(None, description="Optional user ID filter")
days_back: int = Field(30, description="Number of days to look back", ge=7, le=90)
drop_threshold: float = Field(
10.0, description="Alert threshold percentage", ge=1.0, le=50.0
)
include_historical: bool = Field(
False, description="Include historical data for charts"
)
router = APIRouter(
prefix="/admin",
tags=["admin", "execution_analytics"],
@@ -419,3 +435,40 @@ async def _process_batch(
return await asyncio.gather(
*[process_single_execution(execution) for execution in executions]
)
@router.get(
"/execution_accuracy_trends",
response_model=AccuracyTrendsResponse,
summary="Get Execution Accuracy Trends and Alerts",
)
async def get_execution_accuracy_trends(
graph_id: str,
user_id: Optional[str] = None,
days_back: int = 30,
drop_threshold: float = 10.0,
include_historical: bool = False,
admin_user_id: str = Security(get_user_id),
) -> AccuracyTrendsResponse:
"""
Get execution accuracy trends with moving averages and alert detection.
Simple single-query approach.
"""
logger.info(
f"Admin user {admin_user_id} requesting accuracy trends for graph {graph_id}"
)
try:
result = await get_accuracy_trends_and_alerts(
graph_id=graph_id,
days_back=days_back,
user_id=user_id,
drop_threshold=drop_threshold,
include_historical=include_historical,
)
return result
except Exception as e:
logger.exception(f"Error getting accuracy trends for graph {graph_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(
tags=["executions", "review", "private"],
tags=["v2", "executions", "review"],
dependencies=[Security(autogpt_auth_lib.requires_user)],
)

View File

@@ -185,6 +185,12 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="Number of top blocks with most errors to show when no blocks exceed threshold (0 to disable).",
)
# Execution Accuracy Monitoring
execution_accuracy_check_interval_hours: int = Field(
default=24,
description="Interval in hours between execution accuracy alert checks.",
)
model_config = SettingsConfigDict(
env_file=".env",
extra="allow",

View File

@@ -1,6 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
@@ -18,9 +28,12 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import {
usePostV2GenerateExecutionAnalytics,
useGetV2GetExecutionAnalyticsConfiguration,
useGetV2GetExecutionAccuracyTrendsAndAlerts,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { ExecutionAnalyticsRequest } from "@/app/api/__generated__/models/executionAnalyticsRequest";
import type { ExecutionAnalyticsResponse } from "@/app/api/__generated__/models/executionAnalyticsResponse";
import type { AccuracyTrendsResponse } from "@/app/api/__generated__/models/accuracyTrendsResponse";
import type { AccuracyLatestData } from "@/app/api/__generated__/models/accuracyLatestData";
// Use the generated type with minimal adjustment for form handling
interface FormData extends Omit<ExecutionAnalyticsRequest, "created_after"> {
@@ -33,8 +46,133 @@ export function ExecutionAnalyticsForm() {
const [results, setResults] = useState<ExecutionAnalyticsResponse | null>(
null,
);
const [trendsData, setTrendsData] = useState<AccuracyTrendsResponse | null>(
null,
);
const { toast } = useToast();
// State for accuracy trends query parameters
const [accuracyParams, setAccuracyParams] = useState<{
graph_id: string;
user_id?: string;
days_back: number;
drop_threshold: number;
include_historical?: boolean;
} | null>(null);
// Use the generated API client for accuracy trends (GET)
const { data: accuracyApiResponse, error: accuracyError } =
useGetV2GetExecutionAccuracyTrendsAndAlerts(
accuracyParams || {
graph_id: "",
days_back: 30,
drop_threshold: 10.0,
include_historical: false,
},
{
query: {
enabled: !!accuracyParams?.graph_id,
},
},
);
// Update local state when data changes and handle success/error
useEffect(() => {
if (accuracyError) {
console.error("Failed to fetch trends:", accuracyError);
toast({
title: "Trends Error",
description:
(accuracyError as any)?.message || "Failed to fetch accuracy trends",
variant: "destructive",
});
return;
}
const data = accuracyApiResponse?.data;
if (data && "latest_data" in data) {
setTrendsData(data);
// Check for alerts
if (data.alert) {
toast({
title: "🚨 Accuracy Alert Detected",
description: `${data.alert.drop_percent.toFixed(1)}% accuracy drop detected for this agent`,
variant: "destructive",
});
}
}
}, [accuracyApiResponse, accuracyError, toast]);
// Chart component for accuracy trends
function AccuracyChart({ data }: { data: AccuracyLatestData[] }) {
const chartData = data.map((item) => ({
date: new Date(item.date).toLocaleDateString(),
"Daily Score": item.daily_score,
"3-Day Avg": item.three_day_avg,
"7-Day Avg": item.seven_day_avg,
"14-Day Avg": item.fourteen_day_avg,
}));
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip
formatter={(value) => [`${Number(value).toFixed(2)}%`, ""]}
/>
<Legend />
<Line
type="monotone"
dataKey="Daily Score"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="3-Day Avg"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="7-Day Avg"
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="14-Day Avg"
stroke="#8b5cf6"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
// Function to fetch accuracy trends using generated API client
const fetchAccuracyTrends = (graphId: string, userId?: string) => {
if (!graphId.trim()) return;
setAccuracyParams({
graph_id: graphId.trim(),
user_id: userId?.trim() || undefined,
days_back: 30,
drop_threshold: 10.0,
include_historical: showAccuracyChart, // Include historical data when chart is enabled
});
};
// Fetch configuration from API
const {
data: config,
@@ -50,6 +188,7 @@ export function ExecutionAnalyticsForm() {
}
const result = res.data;
setResults(result);
toast({
title: "Analytics Generated",
description: `Processed ${result.processed_executions} executions. ${result.successful_analytics} successful, ${result.failed_analytics} failed, ${result.skipped_executions} skipped.`,
@@ -58,11 +197,21 @@ export function ExecutionAnalyticsForm() {
},
onError: (error: any) => {
console.error("Analytics generation error:", error);
const errorMessage =
error?.message || error?.detail || "An unexpected error occurred";
const isOpenAIError = errorMessage.includes(
"OpenAI API key not configured",
);
toast({
title: "Analytics Generation Failed",
description:
error?.message || error?.detail || "An unexpected error occurred",
variant: "destructive",
title: isOpenAIError
? "Analytics Generation Skipped"
: "Analytics Generation Failed",
description: isOpenAIError
? "Analytics generation requires OpenAI configuration, but accuracy trends are still available above."
: errorMessage,
variant: isOpenAIError ? "default" : "destructive",
});
},
},
@@ -77,6 +226,9 @@ export function ExecutionAnalyticsForm() {
user_prompt: "", // Will use config default when empty
});
// State for accuracy trends chart toggle
const [showAccuracyChart, setShowAccuracyChart] = useState(true);
// Update form defaults when config loads
useEffect(() => {
if (config?.data && config.status === 200 && !formData.model_name) {
@@ -101,6 +253,11 @@ export function ExecutionAnalyticsForm() {
setResults(null);
// Fetch accuracy trends if chart is enabled
if (showAccuracyChart) {
fetchAccuracyTrends(formData.graph_id, formData.user_id || undefined);
}
// Prepare the request payload
const payload: ExecutionAnalyticsRequest = {
graph_id: formData.graph_id.trim(),
@@ -262,6 +419,18 @@ export function ExecutionAnalyticsForm() {
</Label>
</div>
{/* Show Accuracy Chart Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
id="show_accuracy_chart"
checked={showAccuracyChart}
onCheckedChange={(checked) => setShowAccuracyChart(!!checked)}
/>
<Label htmlFor="show_accuracy_chart" className="text-sm">
Show accuracy trends chart and historical data visualization
</Label>
</div>
{/* Custom System Prompt */}
<div className="space-y-2">
<Label htmlFor="system_prompt">
@@ -370,6 +539,98 @@ export function ExecutionAnalyticsForm() {
</div>
</form>
{/* Accuracy Trends Display */}
{trendsData && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Execution Accuracy Trends</h3>
{/* Alert Section */}
{trendsData.alert && (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 p-4">
<div className="flex items-start">
<span className="text-2xl">🚨</span>
<div className="ml-3 space-y-2">
<h4 className="text-lg font-semibold text-red-800">
Accuracy Alert Detected
</h4>
<p className="text-red-700">
<strong>
{trendsData.alert.drop_percent.toFixed(1)}% accuracy drop
</strong>{" "}
detected for agent{" "}
<code className="rounded bg-red-100 px-1 text-sm">
{formData.graph_id}
</code>
</p>
<div className="space-y-1 text-sm text-red-600">
<p>
3-day average:{" "}
<strong>
{trendsData.alert.three_day_avg.toFixed(2)}%
</strong>
</p>
<p>
7-day average:{" "}
<strong>
{trendsData.alert.seven_day_avg.toFixed(2)}%
</strong>
</p>
<p>
Detected at:{" "}
<strong>
{new Date(
trendsData.alert.detected_at,
).toLocaleString()}
</strong>
</p>
</div>
</div>
</div>
</div>
)}
{/* Latest Data Summary */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{trendsData.latest_data.daily_score?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">Daily Score</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{trendsData.latest_data.three_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">3-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-orange-600">
{trendsData.latest_data.seven_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">7-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{trendsData.latest_data.fourteen_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">14-Day Avg</div>
</div>
</div>
{/* Chart Section - only show when toggle is enabled and historical data exists */}
{showAccuracyChart && trendsData?.historical_data && (
<div className="mt-6">
<h4 className="mb-4 text-lg font-semibold">
Execution Accuracy Trends Chart
</h4>
<div className="rounded-lg border bg-white p-6">
<AccuracyChart data={trendsData.historical_data} />
</div>
</div>
)}
</div>
)}
{results && <AnalyticsResultsTable results={results} />}
</div>
);

View File

@@ -17,12 +17,13 @@ function ExecutionAnalyticsDashboard() {
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Analytics Generation</h2>
<h2 className="mb-4 text-xl font-semibold">
Execution Analytics & Accuracy Monitoring
</h2>
<p className="mb-6 text-gray-600">
This tool will identify completed executions missing activity
summaries or success scores and generate them using AI. Only
executions that meet the criteria and are missing these fields will
be processed.
Generate missing activity summaries and success scores for agent
executions. After generation, accuracy trends and alerts will
automatically be displayed to help monitor agent health over time.
</p>
<Suspense

View File

@@ -1,4 +1,4 @@
import { OAuthPopupResultMessage } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
import { NextResponse } from "next/server";
// This route is intended to be used as the callback for integration OAuth flows,

View File

@@ -141,7 +141,6 @@ export function ChatCredentialsSetup({
onSelectCredentials={(credMeta) =>
handleCredentialSelect(cred.provider, credMeta)
}
hideIfSingleCredentialAvailable={false}
/>
</div>
);

View File

@@ -1,13 +1,11 @@
"use client";
import React from "react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import {
getAgentCredentialsFields,
getAgentInputFields,
getCredentialTypeDisplayName,
renderValue,
} from "./helpers";
@@ -54,32 +52,18 @@ export function AgentInputsReadOnly({
{hasCredentials && (
<div className="flex flex-col gap-6">
{hasInputs && <div className="border-t border-neutral-200 pt-4" />}
{credentialEntries.map(([key, _sub]) => {
{credentialEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
return (
<div key={key} className="flex flex-col gap-4">
<h3 className="text-lg font-medium text-neutral-900">
{toDisplayName(credential.provider)} credentials
</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">Name</span>
<span className="text-neutral-600">
{getCredentialTypeDisplayName(credential.type)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-900">
{credential.title || "Untitled"}
</span>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={credential}
onSelectCredentials={() => {}}
readOnly={true}
/>
);
})}
</div>

View File

@@ -1,189 +1,59 @@
import {
IconKey,
IconKeyPlus,
IconUserPlus,
} from "@/components/__legacy__/ui/icons";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { getHostFromUrl } from "@/lib/utils/url";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FC, useEffect, useMemo, useState } from "react";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaKey,
FaMedium,
FaTwitter,
} from "react-icons/fa";
import { APIKeyCredentialsModal } from "./APIKeyCredentialsModal/APIKeyCredentialsModal";
import { HostScopedCredentialsModal } from "./HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./PasswordCredentialsModal/PasswordCredentialsModal";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { useCredentialsInputs } from "./useCredentialsInputs";
const fallbackIcon = FaKey;
type UseCredentialsInputsReturn = ReturnType<typeof useCredentialsInputs>;
// --8<-- [start:ProviderIconsEmbed]
// Provider icons mapping - uses fallback for unknown providers
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
// --8<-- [end:ProviderIconsEmbed]
function isLoaded(
data: UseCredentialsInputsReturn,
): data is Extract<UseCredentialsInputsReturn, { isLoading: false }> {
return data.isLoading === false;
}
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export const CredentialsInput: FC<{
type Props = {
schema: BlockIOCredentialsSubSchema;
className?: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
hideIfSingleCredentialAvailable?: boolean;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
onLoaded?: (loaded: boolean) => void;
}> = ({
readOnly?: boolean;
};
export function CredentialsInput({
schema,
className,
selectedCredentials,
onSelectCredentials,
siblingInputs,
hideIfSingleCredentialAvailable = true,
onLoaded,
}) => {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
readOnly = false,
}: Props) {
const hookData = useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly,
});
const api = useBackendAPI();
const credentials = useCredentials(schema, siblingInputs);
// Report loaded state to parent
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
// Deselect credentials if they do not exist (e.g. provider was changed)
useEffect(() => {
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials]);
const { hasRelevantCredentials, singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
hasRelevantCredentials: false,
singleCredential: null,
};
}
// Simple logic: if we have any saved credentials, we have relevant credentials
const hasRelevant = credentials.savedCredentials.length > 0;
// Auto-select single credential if only one exists
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
hasRelevantCredentials: hasRelevant,
singleCredential: single,
};
}, [credentials]);
// If only 1 credential is available, auto-select it and hide this input
useEffect(() => {
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials]);
if (
!credentials ||
credentials.isLoading ||
(singleCredential && hideIfSingleCredentialAvailable)
) {
if (!isLoaded(hookData)) {
return null;
}
@@ -194,309 +64,158 @@ export const CredentialsInput: FC<{
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
savedCredentials,
oAuthCallback,
} = credentials;
credentialsToShow,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
// type of error is unkown so we need to use String(error)
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(
() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
},
5 * 60 * 1000,
);
}
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const modals = (
<>
{supportsApiKey && (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsOAuth2 && (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
)}
{supportsUserPassword && (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsHostScoped && (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
</>
);
const fieldHeader = (
<div className="mb-2 flex gap-1">
<span className="text-m green text-gray-900">
{providerName} Credentials
</span>
<InformationTooltip description={schema.description} />
</div>
);
// Show credentials creation UI when no relevant credentials exist
if (!hasRelevantCredentials) {
return (
<div className="mb-4">
{fieldHeader}
<div className={cn("flex flex-row space-x-2", className)}>
{supportsOAuth2 && (
<Button onClick={handleOAuthLogin} size="small">
<ProviderIcon className="mr-2 h-4 w-4" />
{"Sign in with " + providerName}
</Button>
)}
{supportsApiKey && (
<Button
onClick={() => setAPICredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter API key
</Button>
)}
{supportsUserPassword && (
<Button
onClick={() => setUserPasswordCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter username and password
</Button>
)}
{supportsHostScoped && credentials.discriminatorValue && (
<Button
onClick={() => setHostScopedCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
{`Enter sensitive headers for ${getHostFromUrl(credentials.discriminatorValue)}`}
</Button>
)}
</div>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
);
}
function handleValueChange(newValue: string) {
if (newValue === "sign-in") {
// Trigger OAuth2 sign in flow
handleOAuthLogin();
} else if (newValue === "add-api-key") {
// Open API key dialog
setAPICredentialsModalOpen(true);
} else if (newValue === "add-user-password") {
// Open user password dialog
setUserPasswordCredentialsModalOpen(true);
} else if (newValue === "add-host-scoped") {
// Open host-scoped credentials dialog
setHostScopedCredentialsModalOpen(true);
} else {
const selectedCreds = savedCredentials.find((c) => c.id == newValue)!;
{hasCredentialsToShow ? (
<>
{credentialsToShow.length > 1 && !readOnly ? (
<CredentialsSelect
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredentials}
onSelectCredential={handleCredentialSelect}
readOnly={readOnly}
/>
) : (
<div className="mb-4 space-y-2">
{credentialsToShow.map((credential) => {
return (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
})}
</div>
)}
{!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
</Button>
)}
</>
) : (
!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
</Button>
)
)}
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
// title: customTitle, // TODO: add input for title
});
}
}
{!readOnly && (
<>
{supportsApiKey ? (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsOAuth2 ? (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
) : null}
{supportsUserPassword ? (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsHostScoped ? (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
// Saved credentials exist
return (
<div>
{fieldHeader}
{oAuthError ? (
<Text variant="body" className="mt-2 text-red-500">
Error: {oAuthError}
</Text>
) : null}
<Select value={selectedCredentials?.id} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder={schema.placeholder} />
</SelectTrigger>
<SelectContent className="nodrag">
{savedCredentials
.filter((c) => c.type == "oauth2")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
{credentials.title ||
credentials.username ||
`Your ${providerName} account`}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "api_key")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "user_password")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconUserPlus className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "host_scoped")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
<SelectSeparator />
{supportsOAuth2 && (
<SelectItem value="sign-in">
<IconUserPlus className="mr-1.5 inline" />
Sign in with {providerName}
</SelectItem>
)}
{supportsApiKey && (
<SelectItem value="add-api-key">
<IconKeyPlus className="mr-1.5 inline" />
Add new API key
</SelectItem>
)}
{supportsUserPassword && (
<SelectItem value="add-user-password">
<IconUserPlus className="mr-1.5 inline" />
Add new user password
</SelectItem>
)}
{supportsHostScoped && (
<SelectItem value="add-host-scoped">
<IconKey className="mr-1.5 inline" />
Add host-scoped headers
</SelectItem>
)}
</SelectContent>
</Select>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
}

View File

@@ -0,0 +1,102 @@
import { IconKey } from "@/components/__legacy__/ui/icons";
import { Text } from "@/components/atoms/Text/Text";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { cn } from "@/lib/utils";
import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react";
import {
fallbackIcon,
getCredentialDisplayName,
MASKED_KEY_LENGTH,
providerIcons,
} from "../../helpers";
type CredentialRowProps = {
credential: {
id: string;
title?: string;
username?: string;
type: string;
provider: string;
};
provider: string;
displayName: string;
onSelect: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
};
export function CredentialRow({
credential,
provider,
displayName,
onSelect,
onDelete,
readOnly = false,
showCaret = false,
asSelectTrigger = false,
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
return (
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
readOnly || showCaret || asSelectTrigger
? { cursor: showCaret || asSelectTrigger ? "pointer" : "default" }
: undefined
}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900">
<ProviderIcon className="h-3 w-3 text-white" />
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="relative top-1 font-mono tracking-tight"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
interface Props {
credentials: Array<{
id: string;
title?: string;
username?: string;
type: string;
provider: string;
}>;
provider: string;
displayName: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredential: (credentialId: string) => void;
readOnly?: boolean;
}
export function CredentialsSelect({
credentials,
provider,
displayName,
selectedCredentials,
onSelectCredential,
readOnly = false,
}: Props) {
// Auto-select first credential if none is selected
useEffect(() => {
if (!selectedCredentials && credentials.length > 0) {
onSelectCredential(credentials[0].id);
}
}, [selectedCredentials, credentials, onSelectCredential]);
return (
<div className="mb-4 w-full">
<Select
value={selectedCredentials?.id || ""}
onValueChange={(value) => onSelectCredential(value)}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
<SelectValue asChild>
{selectedCredentials ? (
<CredentialRow
credential={{
id: selectedCredentials.id,
title: selectedCredentials.title || undefined,
type: selectedCredentials.type,
provider: selectedCredentials.provider,
}}
provider={provider}
displayName={displayName}
onSelect={() => {}}
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
/>
) : (
<Text variant="large">Select credential</Text>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{credentials.map((credential) => (
<SelectItem key={credential.id} value={credential.id}>
<div className="flex items-center gap-2">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface Props {
credentialToDelete: { id: string; title: string } | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteConfirmationModal({
credentialToDelete,
isDeleting,
onClose,
onConfirm,
}: Props) {
return (
<Dialog
controlled={{
isOpen: credentialToDelete !== null,
set: (open) => {
if (!open) onClose();
},
}}
title="Delete credential"
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete &quot;{credentialToDelete?.title}
&quot;? This action cannot be undone.
</Text>
<Dialog.Footer>
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isDeleting}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,15 +1,15 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import useCredentials from "@/hooks/useCredentials";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
type Props = {
schema: BlockIOCredentialsSubSchema;
@@ -85,7 +85,7 @@ export function PasswordCredentialsModal({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 pt-4"
className="w-[98%] space-y-2 pt-4"
>
<FormField
control={form.control}
@@ -96,7 +96,6 @@ export function PasswordCredentialsModal({
label="Username"
type="text"
placeholder="Enter username..."
size="small"
{...field}
/>
)}
@@ -110,7 +109,6 @@ export function PasswordCredentialsModal({
label="Password"
type="password"
placeholder="Enter password..."
size="small"
{...field}
/>
)}
@@ -124,12 +122,12 @@ export function PasswordCredentialsModal({
label="Name"
type="text"
placeholder="Enter a name for this user login..."
size="small"
className="mb-8"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
<Button type="submit" className="w-full">
Save & use this user login
</Button>
</form>

View File

@@ -0,0 +1,102 @@
import { KeyIcon } from "@phosphor-icons/react";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaMedium,
FaTwitter,
} from "react-icons/fa";
export const fallbackIcon = KeyIcon;
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export function getActionButtonText(
supportsOAuth2: boolean,
supportsApiKey: boolean,
supportsUserPassword: boolean,
supportsHostScoped: boolean,
hasExistingCredentials: boolean,
): string {
if (hasExistingCredentials) {
if (supportsOAuth2) return "Connect a different account";
if (supportsApiKey) return "Use a different API key";
if (supportsUserPassword) return "Use a different username and password";
if (supportsHostScoped) return "Use different headers";
return "Add credentials";
} else {
if (supportsOAuth2) return "Add account";
if (supportsApiKey) return "Add API key";
if (supportsUserPassword) return "Add username and password";
if (supportsHostScoped) return "Add headers";
return "Add credentials";
}
}
export function getCredentialDisplayName(
credential: { title?: string; username?: string },
displayName: string,
): string {
return (
credential.title || credential.username || `Your ${displayName} account`
);
}
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;

View File

@@ -0,0 +1,318 @@
import { useDeleteV1DeleteCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useMemo, useState } from "react";
import {
getActionButtonText,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
type Args = {
schema: BlockIOCredentialsSubSchema;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly = false,
}: Args) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
const [credentialToDelete, setCredentialToDelete] = useState<{
id: string;
title: string;
} | null>(null);
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const allProviders = useContext(CredentialsProvidersContext);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["/api/integrations/credentials"],
});
queryClient.invalidateQueries({
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
});
setCredentialToDelete(null);
if (selectedCredentials?.id === credentialToDelete?.id) {
onSelectCredentials(undefined);
}
},
},
});
const rawProvider = credentials
? allProviders?.[credentials.provider as keyof typeof allProviders]
: null;
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials, readOnly]);
const { singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
singleCredential: null,
};
}
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
singleCredential: single,
};
}, [credentials]);
useEffect(() => {
if (readOnly) return;
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials, readOnly]);
if (
!credentials ||
credentials.isLoading ||
!("savedCredentials" in credentials)
) {
return {
isLoading: true,
};
}
const {
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
savedCredentials,
oAuthCallback,
} = credentials;
const allSavedCredentials = rawProvider?.savedCredentials || savedCredentials;
const credentialsToShow = (() => {
const creds = [...allSavedCredentials];
if (
!readOnly &&
selectedCredentials &&
!creds.some((c) => c.id === selectedCredentials.id)
) {
creds.push({
id: selectedCredentials.id,
type: selectedCredentials.type,
title: selectedCredentials.title || "Selected credential",
provider: provider,
} as any);
}
return creds;
})();
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
}, OAUTH_TIMEOUT_MS);
}
function handleActionButtonClick() {
if (supportsOAuth2) {
handleOAuthLogin();
} else if (supportsApiKey) {
setAPICredentialsModalOpen(true);
} else if (supportsUserPassword) {
setUserPasswordCredentialsModalOpen(true);
} else if (supportsHostScoped) {
setHostScopedCredentialsModalOpen(true);
}
}
function handleCredentialSelect(credentialId: string) {
const selectedCreds = credentialsToShow.find((c) => c.id === credentialId);
if (selectedCreds) {
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
title: (selectedCreds as any).title,
});
}
}
function handleDeleteCredential(credential: { id: string; title: string }) {
setCredentialToDelete(credential);
}
function handleDeleteConfirm() {
if (credentialToDelete && credentials) {
deleteCredentialsMutation.mutate({
provider: credentials.provider,
credId: credentialToDelete.id,
});
}
}
return {
isLoading: false as const,
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
selectedCredentials,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText: getActionButtonText(
supportsOAuth2,
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
credentialsToShow.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
onSelectCredentials,
schema,
siblingInputs,
};
}

View File

@@ -5,12 +5,8 @@ import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutio
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { AlarmIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";
import { AgentDetails } from "./components/AgentDetails/AgentDetails";
import { AgentSectionHeader } from "./components/AgentSectionHeader/AgentSectionHeader";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
import { RunActions } from "./components/RunActions/RunActions";
@@ -122,59 +118,34 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div className="flex h-full flex-col pb-4">
{/* Header */}
<div className="flex-shrink-0">
<ModalHeader agent={agent} />
<AgentCostSection flowId={agent.graph_id} />
</div>
{/* Header */}
<ModalHeader agent={agent} />
{/* Scrollable content */}
<div className="flex-1 pr-1" style={{ scrollbarGutter: "stable" }}>
{/* Setup Section */}
<div className="mt-10">
{hasAnySetupFields ? (
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<>
<AgentSectionHeader
title={
defaultRunType === "automatic-trigger"
? "Trigger Setup"
: "Agent Setup"
}
/>
<ModalRunSection />
</>
</RunAgentModalContextProvider>
) : null}
</div>
{/* Agent Details Section */}
<div className="mt-8">
<AgentSectionHeader title="Agent Details" />
<AgentDetails agent={agent} />
</div>
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
</div>
<Dialog.Footer
className="fixed bottom-1 left-0 z-10 w-full bg-white p-4"
style={{ boxShadow: "0px -8px 10px white" }}
>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
<Button
variant="secondary"
@@ -183,8 +154,7 @@ export function RunAgentModal({
isExecuting || isSettingUpTrigger || !allRequiredInputsAreSet
}
>
<AlarmIcon size={16} />
Schedule Agent
Schedule Task
</Button>
<RunActions
defaultRunType={defaultRunType}

View File

@@ -1,31 +0,0 @@
import { Button } from "@/components/atoms/Button/Button";
interface Props {
flowId: string;
}
export function AgentCostSection({ flowId }: Props) {
return (
<div className="mt-6 flex items-center justify-between">
{/* TODO: enable once we have an API to show estimated cost for an agent run */}
{/* <div className="flex items-center gap-2">
<Text variant="body-medium">Cost</Text>
<Text variant="body">{cost}</Text>
</div> */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="small"
as="NextLink"
href={`/build?flowID=${flowId}`}
>
Open in builder
</Button>
{/* TODO: enable once we can easily link to the agent listing page from the library agent response */}
{/* <Button variant="outline" size="small">
View listing <ArrowSquareOutIcon size={16} />
</Button> */}
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Badge } from "@/components/atoms/Badge/Badge";
import { formatDate } from "@/lib/utils/time";
interface Props {
agent: LibraryAgent;
}
export function AgentDetails({ agent }: Props) {
return (
<div className="mt-4 flex flex-col gap-5">
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Version
</Text>
<div className="flex items-center gap-2">
<Text variant="body" className="!text-zinc-700">
v{agent.graph_version}
</Text>
{agent.is_latest_version && (
<Badge variant="success" size="small">
Latest
</Badge>
)}
</div>
</div>
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Last Updated
</Text>
<Text variant="body" className="!text-zinc-700">
{formatDate(agent.updated_at)}
</Text>
</div>
{agent.has_external_trigger && (
<div>
<Text variant="body-medium" className="mb-1">
Trigger Type
</Text>
<Text variant="body" className="!text-neutral-700">
External Webhook
</Text>
</div>
)}
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
}
export function AgentSectionHeader({ title }: Props) {
return (
<div className="border-t border-zinc-400 px-0 pb-2 pt-1">
<Text variant="label" className="!text-zinc-700">
{title}
</Text>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { Badge } from "@/components/atoms/Badge/Badge";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Link } from "@/components/atoms/Link/Link";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { ClockIcon, InfoIcon } from "@phosphor-icons/react";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
interface ModalHeaderProps {
@@ -10,49 +10,56 @@ interface ModalHeaderProps {
}
export function ModalHeader({ agent }: ModalHeaderProps) {
const isUnknownCreator = agent.creator_name === "Unknown";
const creator = agent.marketplace_listing?.creator;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Badge variant="info">New Run</Badge>
</div>
<div className="flex flex-col gap-4">
<Badge variant="info" className="w-fit">
New Task
</Badge>
<div>
<Text variant="h3">{agent.name}</Text>
{!isUnknownCreator ? (
<Text variant="body-medium">by {agent.creator_name}</Text>
<Text variant="h2">{agent.name}</Text>
{creator ? (
<Link href={`/marketplace/creator/${creator.slug}`} isExternal>
by {creator.name}
</Link>
) : null}
<ShowMoreText
previewLimit={80}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
{/* Schedule recommendation tip */}
{agent.recommended_schedule_cron && !agent.has_external_trigger && (
<div className="mt-4 flex items-center gap-2">
<ClockIcon className="h-4 w-4 text-gray-500" />
<p className="text-sm text-gray-600">
<strong>Tip:</strong> For best results, run this agent{" "}
{agent.description ? (
<ShowMoreText
previewLimit={400}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
) : null}
{agent.recommended_schedule_cron && !agent.has_external_trigger ? (
<div className="flex flex-col gap-4 rounded-medium border border-blue-100 bg-blue-50 p-4">
<Text variant="lead-semibold" className="text-blue-600">
Tip
</Text>
<Text variant="body">
For best results, run this agent{" "}
{humanizeCronExpression(
agent.recommended_schedule_cron,
).toLowerCase()}
</p>
</Text>
</div>
)}
) : null}
{/* Setup Instructions */}
{agent.instructions && (
<div className="mt-4 flex items-start gap-2">
<InfoIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
<div className="text-sm text-gray-600">
<strong>Setup Instructions:</strong>{" "}
<span className="whitespace-pre-wrap">{agent.instructions}</span>
</div>
{agent.instructions ? (
<div className="flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#F1EBFE/5] p-4">
<Text variant="lead-semibold" className="text-purple-600">
Instructions
</Text>
<div className="h-px w-full bg-purple-100" />
<Text variant="body">{agent.instructions}</Text>
</div>
)}
) : null}
</div>
</div>
);

View File

@@ -1,17 +1,13 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { InfoIcon } from "@phosphor-icons/react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
import { getCredentialTypeDisplayName } from "./helpers";
export function ModalRunSection() {
const {
agent,
defaultRunType,
presetName,
setPresetName,
@@ -25,147 +21,104 @@ export function ModalRunSection() {
agentCredentialsInputFields,
} = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
return (
<div className="mb-10 mt-4">
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
{/* Preset/Trigger fields */}
{defaultRunType === "automatic-trigger" && (
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
<div className="flex flex-col gap-4">
{defaultRunType === "automatic-trigger" ? (
<ModalSection
title="Task Trigger"
subtitle="Set up a trigger for the agent to run this task automatically"
>
<WebhookTriggerBanner />
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</ModalSection>
) : null}
{inputFields.length > 0 ? (
<ModalSection
title="Task Setup"
subtitle="Enter the information needed for the agent to run this task"
>
{/* Regular inputs */}
{inputFields.map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
</ModalSection>
) : null}
{credentialFields.length > 0 ? (
<ModalSection
title="Task Credentials"
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
/>
),
)}
</div>
</div>
)}
{/* Instructions */}
{agent.instructions && (
<div className="mb-4 flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3">
<InfoIcon className="mt-0.5 h-4 w-4 text-blue-600" />
<div>
<h4 className="text-sm font-medium text-blue-900">
How to use this agent
</h4>
<p className="mt-1 whitespace-pre-wrap text-sm text-blue-800">
{agent.instructions}
</p>
</div>
</div>
)}
{/* Credentials inputs */}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
hideIfSingleCredentialAvailable={!agent.has_external_trigger}
/>
),
)}
{/* Regular inputs */}
{Object.entries(agentInputFields || {}).map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
{/* Selected Credentials Preview */}
{Object.keys(inputCredentials).length > 0 && (
<div className="mt-6 flex flex-col gap-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, _sub]) => {
const credential = inputCredentials[key];
if (!credential) return null;
return (
<div key={key} className="flex flex-col gap-4">
<Text variant="body-medium" as="h3">
{toDisplayName(credential.provider)} credentials
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
Name
</Text>
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
{getCredentialTypeDisplayName(credential.type)}
</Text>
</div>
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-900"
>
{credential.title || "Untitled"}
</Text>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
);
},
)}
</div>
)}
</ModalSection>
) : null}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
subtitle: string;
children: React.ReactNode;
}
export function ModalSection({ title, subtitle, children }: Props) {
return (
<div className="rounded-medium border border-zinc-200 p-6">
<div className="mb-4 flex flex-col gap-1 border-b border-zinc-100 pb-4">
<Text variant="lead-semibold">{title}</Text>
<Text variant="body" className="text-zinc-500">
{subtitle}
</Text>
</div>
{children}
</div>
);
}

View File

@@ -26,7 +26,7 @@ export function RunActions({
>
{defaultRunType === "automatic-trigger"
? "Set up Trigger"
: "Run Agent"}
: "Start Task"}
</Button>
</div>
);

View File

@@ -0,0 +1,100 @@
import { ApiError } from "@/lib/autogpt-server-api/helpers";
import Link from "next/link";
import React from "react";
type ValidationErrorDetail = {
type: string;
message?: string;
node_errors?: Record<string, Record<string, string>>;
};
type AgentInfo = {
graph_id: string;
graph_version: number;
};
export function formatValidationError(
error: any,
agentInfo?: AgentInfo,
): string | React.ReactNode {
if (
!(error instanceof ApiError) ||
!error.isGraphValidationError() ||
!error.response?.detail
) {
return error.message || "An unexpected error occurred.";
}
const detail: ValidationErrorDetail = error.response.detail;
// Format validation errors nicely
if (detail.type === "validation_error" && detail.node_errors) {
const nodeErrors = detail.node_errors;
const errorItems: React.ReactNode[] = [];
// Collect all field errors
Object.entries(nodeErrors).forEach(([nodeId, fields]) => {
if (fields && typeof fields === "object") {
Object.entries(fields).forEach(([fieldName, fieldError]) => {
errorItems.push(
<div key={`${nodeId}-${fieldName}`} className="mt-1">
<span className="font-medium">{fieldName}:</span>{" "}
{String(fieldError)}
</div>,
);
});
}
});
if (errorItems.length > 0) {
return (
<div className="space-y-1">
<div className="font-medium text-white">
{detail.message || "Validation failed"}
</div>
<div className="mt-2 space-y-1 text-xs">{errorItems}</div>
{agentInfo && (
<div className="mt-3 text-xs">
Check the agent graph and try to run from there for further
details.{" "}
<Link
href={`/build?flowID=${agentInfo.graph_id}&flowVersion=${agentInfo.graph_version}`}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer underline hover:no-underline"
>
Open in builder
</Link>
</div>
)}
</div>
);
} else {
return detail.message || "Validation failed";
}
}
return detail.message || error.message || "An unexpected error occurred.";
}
export function showExecutionErrorToast(
toast: (options: {
title: string;
description: string | React.ReactNode;
variant: "destructive";
duration: number;
dismissable: boolean;
}) => void,
error: any,
agentInfo?: AgentInfo,
) {
const errorMessage = formatValidationError(error, agentInfo);
toast({
title: "Failed to execute agent",
description: errorMessage,
variant: "destructive",
duration: 10000, // 10 seconds - long enough to read and close
dismissable: true,
});
}

View File

@@ -1,22 +1,23 @@
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import {
getGetV1ListGraphExecutionsInfiniteQueryOptions,
usePostV1ExecuteGraphAgent,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
import {
getGetV1ListExecutionSchedulesForAGraphQueryKey,
usePostV1CreateExecutionSchedule as useCreateSchedule,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useState, useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import {
usePostV1ExecuteGraphAgent,
getGetV1ListGraphExecutionsInfiniteQueryOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
usePostV1CreateExecutionSchedule as useCreateSchedule,
getGetV1ListExecutionSchedulesForAGraphQueryKey,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
| "manual"
@@ -85,14 +86,9 @@ export function useAgentRunModal(
}
},
onError: (error: any) => {
const errorMessage = error.isGraphValidationError()
? error.response.detail.message
: error.message;
toast({
title: "❌ Failed to execute agent",
description: errorMessage || "An unexpected error occurred.",
variant: "destructive",
showExecutionErrorToast(toast, error, {
graph_id: agent.graph_id,
graph_version: agent.graph_version,
});
},
},

View File

@@ -15,7 +15,13 @@ export function RunDetailCard({ children, className, title }: Props) {
className,
)}
>
{title && <Text variant="lead-semibold">{title}</Text>}
{title ? (
typeof title === "string" ? (
<Text variant="lead-semibold">{title}</Text>
) : (
title
)
) : null}
{children}
</div>
);

View File

@@ -129,7 +129,8 @@ export function SelectedRunView({
<div id="summary" className="scroll-mt-4">
<RunDetailCard
title={
<div>
<div className="flex items-center gap-2">
<Text variant="lead-semibold">Summary</Text>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -40,8 +40,8 @@ import { cn, isEmpty } from "@/lib/utils";
import { ClockIcon, CopyIcon, InfoIcon } from "@phosphor-icons/react";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
import { analytics } from "@/services/analytics";
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
export function AgentRunDraftView({
graph,
@@ -674,9 +674,6 @@ export function AgentRunDraftView({
prev.add("credentials"),
);
}}
hideIfSingleCredentialAvailable={
!agentPreset && !graph.has_external_trigger
}
/>
),
)}

View File

@@ -1,5 +1,5 @@
"use client";
import { providerIcons } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { IconKey, IconUser } from "@/components/__legacy__/ui/icons";
import LoadingBox from "@/components/__legacy__/ui/loading";
import {
@@ -13,6 +13,7 @@ import {
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { providerIcons } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
import { CredentialsProviderName } from "@/lib/autogpt-server-api";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";

View File

@@ -4072,16 +4072,84 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/executions/admin/execution_accuracy_trends": {
"get": {
"tags": ["v2", "admin", "admin", "execution_analytics"],
"summary": "Get Execution Accuracy Trends and Alerts",
"description": "Get execution accuracy trends with moving averages and alert detection.\nSimple single-query approach.",
"operationId": "getV2Get execution accuracy trends and alerts",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "graph_id",
"in": "query",
"required": true,
"schema": { "type": "string", "title": "Graph Id" }
},
{
"name": "user_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
}
},
{
"name": "days_back",
"in": "query",
"required": false,
"schema": { "type": "integer", "default": 30, "title": "Days Back" }
},
{
"name": "drop_threshold",
"in": "query",
"required": false,
"schema": {
"type": "number",
"default": 10.0,
"title": "Drop Threshold"
}
},
{
"name": "include_historical",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Include Historical"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccuracyTrendsResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/review/pending": {
"get": {
"tags": [
"v2",
"executions",
"review",
"executions",
"review",
"private"
],
"tags": ["v2", "executions", "review", "v2", "executions", "review"],
"summary": "Get Pending Reviews",
"description": "Get all pending reviews for the current user.\n\nRetrieves all reviews with status \"WAITING\" that belong to the authenticated user.\nResults are ordered by creation time (newest first).\n\nArgs:\n user_id: Authenticated user ID from security dependency\n\nReturns:\n List of pending review objects with status converted to typed literals\n\nRaises:\n HTTPException: If authentication fails or database error occurs\n\nNote:\n Reviews with invalid status values are logged as warnings but excluded\n from results rather than failing the entire request.",
"operationId": "getV2Get pending reviews",
@@ -4150,14 +4218,7 @@
},
"/api/review/execution/{graph_exec_id}": {
"get": {
"tags": [
"v2",
"executions",
"review",
"executions",
"review",
"private"
],
"tags": ["v2", "executions", "review", "v2", "executions", "review"],
"summary": "Get Pending Reviews for Execution",
"description": "Get all pending reviews for a specific graph execution.\n\nRetrieves all reviews with status \"WAITING\" for the specified graph execution\nthat belong to the authenticated user. Results are ordered by creation time\n(oldest first) to preserve review order within the execution.\n\nArgs:\n graph_exec_id: ID of the graph execution to get reviews for\n user_id: Authenticated user ID from security dependency\n\nReturns:\n List of pending review objects for the specified execution\n\nRaises:\n HTTPException:\n - 403: If user doesn't own the graph execution\n - 500: If authentication fails or database error occurs\n\nNote:\n Only returns reviews owned by the authenticated user for security.\n Reviews with invalid status are excluded with warning logs.",
"operationId": "getV2Get pending reviews for execution",
@@ -4207,14 +4268,7 @@
},
"/api/review/action": {
"post": {
"tags": [
"v2",
"executions",
"review",
"executions",
"review",
"private"
],
"tags": ["v2", "executions", "review", "v2", "executions", "review"],
"summary": "Process Review Action",
"description": "Process reviews with approve or reject actions.",
"operationId": "postV2ProcessReviewAction",
@@ -5429,6 +5483,90 @@
"enum": ["ACTIVE", "REVOKED", "SUSPENDED"],
"title": "APIKeyStatus"
},
"AccuracyAlertData": {
"properties": {
"graph_id": { "type": "string", "title": "Graph Id" },
"user_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
},
"drop_percent": { "type": "number", "title": "Drop Percent" },
"three_day_avg": { "type": "number", "title": "Three Day Avg" },
"seven_day_avg": { "type": "number", "title": "Seven Day Avg" },
"detected_at": {
"type": "string",
"format": "date-time",
"title": "Detected At"
}
},
"type": "object",
"required": [
"graph_id",
"user_id",
"drop_percent",
"three_day_avg",
"seven_day_avg",
"detected_at"
],
"title": "AccuracyAlertData",
"description": "Alert data when accuracy drops significantly."
},
"AccuracyLatestData": {
"properties": {
"date": { "type": "string", "format": "date-time", "title": "Date" },
"daily_score": {
"anyOf": [{ "type": "number" }, { "type": "null" }],
"title": "Daily Score"
},
"three_day_avg": {
"anyOf": [{ "type": "number" }, { "type": "null" }],
"title": "Three Day Avg"
},
"seven_day_avg": {
"anyOf": [{ "type": "number" }, { "type": "null" }],
"title": "Seven Day Avg"
},
"fourteen_day_avg": {
"anyOf": [{ "type": "number" }, { "type": "null" }],
"title": "Fourteen Day Avg"
}
},
"type": "object",
"required": [
"date",
"daily_score",
"three_day_avg",
"seven_day_avg",
"fourteen_day_avg"
],
"title": "AccuracyLatestData",
"description": "Latest execution accuracy data point."
},
"AccuracyTrendsResponse": {
"properties": {
"latest_data": { "$ref": "#/components/schemas/AccuracyLatestData" },
"alert": {
"anyOf": [
{ "$ref": "#/components/schemas/AccuracyAlertData" },
{ "type": "null" }
]
},
"historical_data": {
"anyOf": [
{
"items": { "$ref": "#/components/schemas/AccuracyLatestData" },
"type": "array"
},
{ "type": "null" }
],
"title": "Historical Data"
}
},
"type": "object",
"required": ["latest_data", "alert"],
"title": "AccuracyTrendsResponse",
"description": "Response model for accuracy trends and alerts."
},
"AddUserCreditsResponse": {
"properties": {
"new_balance": { "type": "integer", "title": "New Balance" },

View File

@@ -15,6 +15,7 @@ export function Button(props: ButtonProps) {
variant,
size,
loading = false,
withTooltip = true,
leftIcon,
rightIcon,
children,
@@ -28,7 +29,9 @@ export function Button(props: ButtonProps) {
// Extract aria-label for tooltip on icon variant
const ariaLabel =
"aria-label" in restProps ? restProps["aria-label"] : undefined;
const shouldShowTooltip = variant === "icon" && ariaLabel && !loading;
const shouldShowTooltip =
variant === "icon" && ariaLabel && !loading && withTooltip;
// Helper to wrap button with tooltip if needed
const wrapWithTooltip = (buttonElement: React.ReactElement) => {

View File

@@ -16,7 +16,7 @@ export const extendedButtonVariants = cva(
primary:
"bg-zinc-800 border-zinc-800 text-white hover:bg-zinc-900 hover:border-zinc-900 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
secondary:
"bg-zinc-200 border-zinc-200 text-black hover:bg-zinc-400 hover:border-zinc-400 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
"bg-zinc-100 border-zinc-100 text-black hover:bg-zinc-300 hover:border-zinc-300 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
destructive:
"bg-red-500 border-red-500 text-white hover:bg-red-600 hover:border-red-600 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
outline:
@@ -49,6 +49,7 @@ type BaseButtonProps = {
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
asChild?: boolean;
withTooltip?: boolean;
} & VariantProps<typeof extendedButtonVariants>;
type ButtonAsButton = BaseButtonProps &

View File

@@ -92,7 +92,7 @@ export function Input({
className={cn(
baseStyles,
errorStyles,
"-mb-1 h-auto min-h-[2.875rem]",
"-mb-1 h-auto min-h-[2.875rem] rounded-medium",
// Size variants for textarea
size === "small" && [
"min-h-[2.25rem]", // 36px minimum
@@ -222,7 +222,7 @@ export function Input({
) : (
<label htmlFor={props.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Text variant="body-medium" as="span" className="text-black">
<Text variant="large-medium" as="span" className="text-black">
{label}
</Text>
{hint ? (

View File

@@ -1,15 +1,15 @@
"use client";
import * as React from "react";
import {
Select as BaseSelect,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectSeparator,
} from "@/components/__legacy__/ui/select";
import { cn } from "@/lib/utils";
import * as React from "react";
import { ReactNode } from "react";
import { Text } from "../Text/Text";
@@ -140,7 +140,7 @@ export function Select({
) : (
<label htmlFor={id} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Text variant="body-medium" as="span" className="text-black">
<Text variant="large-medium" as="span" className="text-black">
{label}
</Text>
{hint}

View File

@@ -31,7 +31,6 @@ export function GoogleDrivePicker(props: Props) {
schema={credentials.schema}
selectedCredentials={selectedCredential}
onSelectCredentials={setSelectedCredential}
hideIfSingleCredentialAvailable
/>
);
}
@@ -46,7 +45,6 @@ export function GoogleDrivePicker(props: Props) {
schema={credentials.schema}
selectedCredentials={selectedCredential}
onSelectCredentials={setSelectedCredential}
hideIfSingleCredentialAvailable={false}
/>
)}
<Button

View File

@@ -1,16 +1,17 @@
"use client";
import * as React from "react";
import { CronExpressionDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { StepHeader } from "../StepHeader";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { CronExpressionDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
import { Text } from "@/components/atoms/Text/Text";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { CalendarClockIcon } from "lucide-react";
import { Props, useAgentInfoStep } from "./useAgentInfoStep";
import * as React from "react";
import { StepHeader } from "../StepHeader";
import { ThumbnailImages } from "./components/ThumbnailImages";
import { Props, useAgentInfoStep } from "./useAgentInfoStep";
export function AgentInfoStep({
onBack,
@@ -197,10 +198,8 @@ export function AgentInfoStep({
control={form.control}
name="recommendedScheduleCron"
render={({ field }) => (
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
Recommended Schedule
</label>
<div className="mb-8 flex flex-col space-y-2">
<Text variant="large-medium">Recommended Schedule</Text>
<p className="text-xs text-gray-600">
Suggest when users should run this agent for best results
</p>

View File

@@ -1,12 +1,11 @@
"use client";
import * as React from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { MagicWand } from "@phosphor-icons/react";
import Image from "next/image";
import { IconCross, IconPlus } from "../../../../../__legacy__/ui/icons";
import { Button } from "@/components/atoms/Button/Button";
import { MagicWand } from "@phosphor-icons/react";
import { useThumbnailImages } from "./useThumbnailImages";
import { Text } from "@/components/atoms/Text/Text";
interface ThumbnailImagesProps {
agentId: string | null;
@@ -43,9 +42,9 @@ export function ThumbnailImages({
return (
<div className="space-y-2.5">
<div className="flex flex-col items-start justify-start gap-1">
<label className="text-sm font-medium leading-tight text-slate-950">
<Text variant="large-medium" className="leading-tight">
Thumbnail images
</label>
</Text>
<Text variant="body" className="!text-zinc-500">
The first image will be used as the thumbnail for your agent.
</Text>
@@ -68,7 +67,7 @@ export function ThumbnailImages({
</div>
<div
ref={thumbnailsContainerRef}
className="flex items-center space-x-2 overflow-x-auto pb-6"
className="relative z-10 flex items-center space-x-2 pb-4"
>
{images.length === 0 ? (
<div className="flex w-full items-center justify-start gap-2 pl-2">
@@ -108,15 +107,13 @@ export function ThumbnailImages({
key={index}
className="relative flex-shrink-0 overflow-visible"
>
<Button
type="button"
size="small"
<button
onClick={() => handleRemoveImage(index)}
className="absolute right-0 top-0 z-50 h-6 w-6 p-0"
className="absolute -right-2 -top-2 z-50 inline-flex size-6 items-center justify-center rounded-full bg-slate-900"
aria-label="Remove image"
>
<IconCross className="h-2 w-2 text-white" />
</Button>
</button>
<div
className={`relative aspect-video h-16 w-24 overflow-hidden rounded-md border-2 transition-colors ${
selectedImage === src

View File

@@ -1,3 +1,5 @@
import { Button } from "@/components/atoms/Button/Button";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
import * as RXDialog from "@radix-ui/react-dialog";
@@ -11,7 +13,6 @@ import {
} from "react";
import { DialogCtx } from "../useDialogCtx";
import { modalStyles } from "./styles";
import { scrollbarStyles } from "@/components/styles/scrollbars";
type BaseProps = DialogCtx & PropsWithChildren;
@@ -111,13 +112,16 @@ export function DialogWrap({
)}
{isForceOpen && !handleClose ? null : (
<button
<Button
variant="icon"
size="icon"
onClick={handleClose}
aria-label="Close"
className="absolute right-4 top-4 z-50 hover:border-transparent hover:bg-transparent focus:border-none focus:outline-none"
className="absolute right-4 top-4 z-50 size-[2.5rem] bg-white"
withTooltip={false}
>
<X className={modalStyles.icon} />
</button>
<X width="1rem" />
</Button>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">

View File

@@ -37,3 +37,11 @@ html body .toastDescription {
font-size: 0.75rem !important;
line-height: 1.25rem !important;
}
/* Position close button on the right */
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
#root [data-sonner-toast] [data-close-button="true"] {
left: unset !important;
right: -18px !important;
top: -3px !important;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { CheckCircle, Info, Warning, XCircle } from "@phosphor-icons/react";
import { Toaster as SonnerToaster } from "sonner";
import { CheckCircle, XCircle, Warning, Info } from "@phosphor-icons/react";
import styles from "./styles.module.css";
export function Toaster() {
@@ -9,6 +9,7 @@ export function Toaster() {
<SonnerToaster
position="bottom-center"
richColors
closeButton
toastOptions={{
classNames: {
toast: styles.toastDefault,
@@ -20,6 +21,7 @@ export function Toaster() {
info: styles.toastInfo,
},
}}
className="custom__toast"
icons={{
success: <CheckCircle className="h-4 w-4" color="#fff" weight="fill" />,
error: <XCircle className="h-4 w-4" color="#fff" weight="fill" />,