mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into swiftyos/vector-search
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
autogpt_platform/backend/backend/monitoring/accuracy_monitor.py
Normal file
107
autogpt_platform/backend/backend/monitoring/accuracy_monitor.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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)],
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -141,7 +141,6 @@ export function ChatCredentialsSetup({
|
||||
onSelectCredentials={(credMeta) =>
|
||||
handleCredentialSelect(cred.provider, credMeta)
|
||||
}
|
||||
hideIfSingleCredentialAvailable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{credentialToDelete?.title}
|
||||
"? 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function RunActions({
|
||||
>
|
||||
{defaultRunType === "automatic-trigger"
|
||||
? "Set up Trigger"
|
||||
: "Run Agent"}
|
||||
: "Start Task"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
Reference in New Issue
Block a user