mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
14 Commits
master
...
test-scree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7ecaa19c | ||
|
|
28b26dde94 | ||
|
|
d677978c90 | ||
|
|
a347c274b7 | ||
|
|
f79d8f0449 | ||
|
|
1bc48c55d5 | ||
|
|
9d0a31c0f1 | ||
|
|
9b086e39c6 | ||
|
|
5867e4d613 | ||
|
|
f871717f68 | ||
|
|
f08e52dc86 | ||
|
|
500b345b3b | ||
|
|
995dd1b5f3 | ||
|
|
336114f217 |
@@ -0,0 +1,94 @@
|
||||
"""Admin endpoints for checking and resetting user CoPilot rate limit usage."""
|
||||
|
||||
import logging
|
||||
|
||||
from autogpt_libs.auth import get_user_id, requires_admin_user
|
||||
from fastapi import APIRouter, Body, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.copilot.config import ChatConfig
|
||||
from backend.copilot.rate_limit import (
|
||||
get_global_rate_limits,
|
||||
get_usage_status,
|
||||
reset_user_usage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["copilot", "admin"],
|
||||
dependencies=[Security(requires_admin_user)],
|
||||
)
|
||||
|
||||
|
||||
class UserRateLimitResponse(BaseModel):
|
||||
user_id: str
|
||||
daily_token_limit: int
|
||||
weekly_token_limit: int
|
||||
daily_tokens_used: int
|
||||
weekly_tokens_used: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rate_limit",
|
||||
response_model=UserRateLimitResponse,
|
||||
summary="Get User Rate Limit",
|
||||
)
|
||||
async def get_user_rate_limit(
|
||||
user_id: str,
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
) -> UserRateLimitResponse:
|
||||
"""Get a user's current usage and effective rate limits. Admin-only."""
|
||||
logger.info(f"Admin {admin_user_id} checking rate limit for user {user_id}")
|
||||
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
usage = await get_usage_status(user_id, daily_limit, weekly_limit)
|
||||
|
||||
return UserRateLimitResponse(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
daily_tokens_used=usage.daily.used,
|
||||
weekly_tokens_used=usage.weekly.used,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rate_limit/reset",
|
||||
response_model=UserRateLimitResponse,
|
||||
summary="Reset User Rate Limit Usage",
|
||||
)
|
||||
async def reset_user_rate_limit(
|
||||
user_id: str = Body(embed=True),
|
||||
reset_weekly: bool = Body(False, embed=True),
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
) -> UserRateLimitResponse:
|
||||
"""Reset a user's daily usage counter (and optionally weekly). Admin-only."""
|
||||
logger.info(
|
||||
f"Admin {admin_user_id} resetting rate limit for user {user_id} "
|
||||
f"(reset_weekly={reset_weekly})"
|
||||
)
|
||||
|
||||
try:
|
||||
await reset_user_usage(user_id, reset_weekly=reset_weekly)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to reset user usage")
|
||||
raise HTTPException(status_code=500, detail="Failed to reset usage") from e
|
||||
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
usage = await get_usage_status(user_id, daily_limit, weekly_limit)
|
||||
|
||||
return UserRateLimitResponse(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
daily_tokens_used=usage.daily.used,
|
||||
weekly_tokens_used=usage.weekly.used,
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import fastapi
|
||||
import fastapi.testclient
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
from pytest_snapshot.plugin import Snapshot
|
||||
|
||||
from backend.copilot.rate_limit import CoPilotUsageStatus, UsageWindow
|
||||
|
||||
from .rate_limit_admin_routes import router as rate_limit_admin_router
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
app.include_router(rate_limit_admin_router)
|
||||
|
||||
client = fastapi.testclient.TestClient(app)
|
||||
|
||||
_MOCK_MODULE = "backend.api.features.admin.rate_limit_admin_routes"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_app_admin_auth(mock_jwt_admin):
|
||||
"""Setup admin auth overrides for all tests in this module"""
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _mock_usage_status(
|
||||
daily_used: int = 500_000, weekly_used: int = 3_000_000
|
||||
) -> CoPilotUsageStatus:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
now = datetime.now(UTC)
|
||||
return CoPilotUsageStatus(
|
||||
daily=UsageWindow(
|
||||
used=daily_used, limit=2_500_000, resets_at=now + timedelta(hours=6)
|
||||
),
|
||||
weekly=UsageWindow(
|
||||
used=weekly_used, limit=12_500_000, resets_at=now + timedelta(days=3)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_get_rate_limit(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
configured_snapshot: Snapshot,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test getting rate limit and usage for a user."""
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_global_rate_limits",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(2_500_000, 12_500_000),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(),
|
||||
)
|
||||
|
||||
response = client.get("/admin/rate_limit", params={"user_id": target_user_id})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == target_user_id
|
||||
assert data["daily_token_limit"] == 2_500_000
|
||||
assert data["weekly_token_limit"] == 12_500_000
|
||||
assert data["daily_tokens_used"] == 500_000
|
||||
assert data["weekly_tokens_used"] == 3_000_000
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"get_rate_limit",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_daily_only(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
configured_snapshot: Snapshot,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test resetting only daily usage (default behaviour)."""
|
||||
mock_reset = mocker.patch(
|
||||
f"{_MOCK_MODULE}.reset_user_usage",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_global_rate_limits",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(2_500_000, 12_500_000),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(daily_used=0, weekly_used=3_000_000),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["daily_tokens_used"] == 0
|
||||
# Weekly is untouched
|
||||
assert data["weekly_tokens_used"] == 3_000_000
|
||||
|
||||
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=False)
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"reset_user_usage_daily_only",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_daily_and_weekly(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
configured_snapshot: Snapshot,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test resetting both daily and weekly usage."""
|
||||
mock_reset = mocker.patch(
|
||||
f"{_MOCK_MODULE}.reset_user_usage",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_global_rate_limits",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(2_500_000, 12_500_000),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(daily_used=0, weekly_used=0),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id, "reset_weekly": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["daily_tokens_used"] == 0
|
||||
assert data["weekly_tokens_used"] == 0
|
||||
|
||||
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=True)
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"reset_user_usage_daily_and_weekly",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_redis_failure(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test that Redis failure on reset returns 500."""
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.reset_user_usage",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Redis connection refused"),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
def test_admin_endpoints_require_admin_role(mock_jwt_user) -> None:
|
||||
"""Test that rate limit admin endpoints require admin role."""
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
|
||||
|
||||
response = client.get("/admin/rate_limit", params={"user_id": "test"})
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -30,8 +30,14 @@ from backend.copilot.model import (
|
||||
from backend.copilot.rate_limit import (
|
||||
CoPilotUsageStatus,
|
||||
RateLimitExceeded,
|
||||
acquire_reset_lock,
|
||||
check_rate_limit,
|
||||
get_daily_reset_count,
|
||||
get_global_rate_limits,
|
||||
get_usage_status,
|
||||
increment_daily_reset_count,
|
||||
release_reset_lock,
|
||||
reset_daily_usage,
|
||||
)
|
||||
from backend.copilot.response_model import StreamError, StreamFinish, StreamHeartbeat
|
||||
from backend.copilot.tools.e2b_sandbox import kill_sandbox
|
||||
@@ -59,9 +65,16 @@ from backend.copilot.tools.models import (
|
||||
UnderstandingUpdatedResponse,
|
||||
)
|
||||
from backend.copilot.tracking import track_user_message
|
||||
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
|
||||
from backend.data.redis_client import get_redis_async
|
||||
from backend.data.understanding import get_business_understanding
|
||||
from backend.data.workspace import get_or_create_workspace
|
||||
from backend.util.exceptions import NotFoundError
|
||||
from backend.util.exceptions import InsufficientBalanceError, NotFoundError
|
||||
from backend.util.settings import Settings
|
||||
|
||||
settings = Settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
|
||||
@@ -69,8 +82,6 @@ _UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _validate_and_get_session(
|
||||
session_id: str,
|
||||
@@ -421,11 +432,187 @@ async def get_copilot_usage(
|
||||
"""Get CoPilot usage status for the authenticated user.
|
||||
|
||||
Returns current token usage vs limits for daily and weekly windows.
|
||||
Global defaults sourced from LaunchDarkly (falling back to config).
|
||||
"""
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
return await get_usage_status(
|
||||
user_id=user_id,
|
||||
daily_token_limit=config.daily_token_limit,
|
||||
weekly_token_limit=config.weekly_token_limit,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
rate_limit_reset_cost=config.rate_limit_reset_cost,
|
||||
)
|
||||
|
||||
|
||||
class RateLimitResetResponse(BaseModel):
|
||||
"""Response from resetting the daily rate limit."""
|
||||
|
||||
success: bool
|
||||
credits_charged: int = Field(description="Credits charged (in cents)")
|
||||
remaining_balance: int = Field(description="Credit balance after charge (in cents)")
|
||||
usage: CoPilotUsageStatus = Field(description="Updated usage status after reset")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/usage/reset",
|
||||
status_code=200,
|
||||
responses={
|
||||
400: {
|
||||
"description": "Bad Request (feature disabled or daily limit not reached)"
|
||||
},
|
||||
402: {"description": "Payment Required (insufficient credits)"},
|
||||
429: {
|
||||
"description": "Too Many Requests (max daily resets exceeded or reset in progress)"
|
||||
},
|
||||
503: {
|
||||
"description": "Service Unavailable (Redis reset failed; credits refunded or support needed)"
|
||||
},
|
||||
},
|
||||
)
|
||||
async def reset_copilot_usage(
|
||||
user_id: Annotated[str, Security(auth.get_user_id)],
|
||||
) -> RateLimitResetResponse:
|
||||
"""Reset the daily CoPilot rate limit by spending credits.
|
||||
|
||||
Allows users who have hit their daily token limit to spend credits
|
||||
to reset their daily usage counter and continue working.
|
||||
Returns 400 if the feature is disabled or the user is not over the limit.
|
||||
Returns 402 if the user has insufficient credits.
|
||||
"""
|
||||
cost = config.rate_limit_reset_cost
|
||||
if cost <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Rate limit reset is not available.",
|
||||
)
|
||||
|
||||
if not settings.config.enable_credit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Rate limit reset is not available (credit system is disabled).",
|
||||
)
|
||||
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
|
||||
if daily_limit <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No daily limit is configured — nothing to reset.",
|
||||
)
|
||||
|
||||
# Check max daily resets. get_daily_reset_count returns None when Redis
|
||||
# is unavailable; reject the reset in that case to prevent unlimited
|
||||
# free resets when the counter store is down.
|
||||
reset_count = await get_daily_reset_count(user_id)
|
||||
if reset_count is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Unable to verify reset eligibility — please try again later.",
|
||||
)
|
||||
if config.max_daily_resets > 0 and reset_count >= config.max_daily_resets:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"You've used all {config.max_daily_resets} resets for today.",
|
||||
)
|
||||
|
||||
# Acquire a per-user lock to prevent TOCTOU races (concurrent resets).
|
||||
if not await acquire_reset_lock(user_id):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="A reset is already in progress. Please try again.",
|
||||
)
|
||||
|
||||
try:
|
||||
# Verify the user is actually at or over their daily limit.
|
||||
usage_status = await get_usage_status(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
)
|
||||
if daily_limit > 0 and usage_status.daily.used < daily_limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="You have not reached your daily limit yet.",
|
||||
)
|
||||
|
||||
# If the weekly limit is also exhausted, resetting the daily counter
|
||||
# won't help — the user would still be blocked by the weekly limit.
|
||||
if weekly_limit > 0 and usage_status.weekly.used >= weekly_limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Your weekly limit is also reached. Resetting the daily limit won't help.",
|
||||
)
|
||||
|
||||
# Charge credits.
|
||||
credit_model = await get_user_credit_model(user_id)
|
||||
try:
|
||||
remaining = await credit_model.spend_credits(
|
||||
user_id=user_id,
|
||||
cost=cost,
|
||||
metadata=UsageTransactionMetadata(
|
||||
reason="CoPilot daily rate limit reset",
|
||||
),
|
||||
)
|
||||
except InsufficientBalanceError as e:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail="Insufficient credits to reset your rate limit.",
|
||||
) from e
|
||||
|
||||
# Reset daily usage in Redis. If this fails, refund the credits
|
||||
# so the user is not charged for a service they did not receive.
|
||||
if not await reset_daily_usage(user_id, daily_token_limit=daily_limit):
|
||||
# Compensate: refund the charged credits.
|
||||
refunded = False
|
||||
try:
|
||||
await credit_model.top_up_credits(user_id, cost)
|
||||
refunded = True
|
||||
logger.warning(
|
||||
"Refunded %d credits to user %s after Redis reset failure",
|
||||
cost,
|
||||
user_id[:8],
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"CRITICAL: Failed to refund %d credits to user %s "
|
||||
"after Redis reset failure — manual intervention required",
|
||||
cost,
|
||||
user_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
if refunded:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Rate limit reset failed — please try again later. "
|
||||
"Your credits have not been charged.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Rate limit reset failed and the automatic refund "
|
||||
"also failed. Please contact support for assistance.",
|
||||
)
|
||||
|
||||
# Track the reset count for daily cap enforcement.
|
||||
await increment_daily_reset_count(user_id)
|
||||
finally:
|
||||
await release_reset_lock(user_id)
|
||||
|
||||
# Return updated usage status.
|
||||
updated_usage = await get_usage_status(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
rate_limit_reset_cost=config.rate_limit_reset_cost,
|
||||
)
|
||||
|
||||
return RateLimitResetResponse(
|
||||
success=True,
|
||||
credits_charged=cost,
|
||||
remaining_balance=remaining,
|
||||
usage=updated_usage,
|
||||
)
|
||||
|
||||
|
||||
@@ -526,12 +713,16 @@ async def stream_chat_post(
|
||||
|
||||
# Pre-turn rate limit check (token-based).
|
||||
# check_rate_limit short-circuits internally when both limits are 0.
|
||||
# Global defaults sourced from LaunchDarkly, falling back to config.
|
||||
if user_id:
|
||||
try:
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
await check_rate_limit(
|
||||
user_id=user_id,
|
||||
daily_token_limit=config.daily_token_limit,
|
||||
weekly_token_limit=config.weekly_token_limit,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
)
|
||||
except RateLimitExceeded as e:
|
||||
raise HTTPException(status_code=429, detail=str(e)) from e
|
||||
@@ -894,6 +1085,47 @@ async def session_assign_user(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ========== Suggested Prompts ==========
|
||||
|
||||
|
||||
class SuggestedTheme(BaseModel):
|
||||
"""A themed group of suggested prompts."""
|
||||
|
||||
name: str
|
||||
prompts: list[str]
|
||||
|
||||
|
||||
class SuggestedPromptsResponse(BaseModel):
|
||||
"""Response model for user-specific suggested prompts grouped by theme."""
|
||||
|
||||
themes: list[SuggestedTheme]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/suggested-prompts",
|
||||
dependencies=[Security(auth.requires_user)],
|
||||
)
|
||||
async def get_suggested_prompts(
|
||||
user_id: Annotated[str, Security(auth.get_user_id)],
|
||||
) -> SuggestedPromptsResponse:
|
||||
"""
|
||||
Get LLM-generated suggested prompts grouped by theme.
|
||||
|
||||
Returns personalized quick-action prompts based on the user's
|
||||
business understanding. Returns empty themes list if no custom
|
||||
prompts are available.
|
||||
"""
|
||||
understanding = await get_business_understanding(user_id)
|
||||
if understanding is None or not understanding.suggested_prompts:
|
||||
return SuggestedPromptsResponse(themes=[])
|
||||
|
||||
themes = [
|
||||
SuggestedTheme(name=name, prompts=prompts)
|
||||
for name, prompts in understanding.suggested_prompts.items()
|
||||
]
|
||||
return SuggestedPromptsResponse(themes=themes)
|
||||
|
||||
|
||||
# ========== Configuration ==========
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for chat API routes: session title update, file attachment validation, usage, and rate limiting."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import fastapi
|
||||
import fastapi.testclient
|
||||
@@ -368,6 +368,7 @@ def test_usage_returns_daily_and_weekly(
|
||||
user_id=test_user_id,
|
||||
daily_token_limit=10000,
|
||||
weekly_token_limit=50000,
|
||||
rate_limit_reset_cost=chat_routes.config.rate_limit_reset_cost,
|
||||
)
|
||||
|
||||
|
||||
@@ -380,6 +381,7 @@ def test_usage_uses_config_limits(
|
||||
|
||||
mocker.patch.object(chat_routes.config, "daily_token_limit", 99999)
|
||||
mocker.patch.object(chat_routes.config, "weekly_token_limit", 77777)
|
||||
mocker.patch.object(chat_routes.config, "rate_limit_reset_cost", 500)
|
||||
|
||||
response = client.get("/usage")
|
||||
|
||||
@@ -388,6 +390,7 @@ def test_usage_uses_config_limits(
|
||||
user_id=test_user_id,
|
||||
daily_token_limit=99999,
|
||||
weekly_token_limit=77777,
|
||||
rate_limit_reset_cost=500,
|
||||
)
|
||||
|
||||
|
||||
@@ -400,3 +403,69 @@ def test_usage_rejects_unauthenticated_request() -> None:
|
||||
response = unauthenticated_client.get("/usage")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ─── Suggested prompts endpoint ──────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_get_business_understanding(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
*,
|
||||
return_value=None,
|
||||
):
|
||||
"""Mock get_business_understanding."""
|
||||
return mocker.patch(
|
||||
"backend.api.features.chat.routes.get_business_understanding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=return_value,
|
||||
)
|
||||
|
||||
|
||||
def test_suggested_prompts_returns_themes(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
test_user_id: str,
|
||||
) -> None:
|
||||
"""User with themed prompts gets them back as themes list."""
|
||||
mock_understanding = MagicMock()
|
||||
mock_understanding.suggested_prompts = {
|
||||
"Learn": ["L1", "L2"],
|
||||
"Create": ["C1"],
|
||||
}
|
||||
_mock_get_business_understanding(mocker, return_value=mock_understanding)
|
||||
|
||||
response = client.get("/suggested-prompts")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "themes" in data
|
||||
themes_by_name = {t["name"]: t["prompts"] for t in data["themes"]}
|
||||
assert themes_by_name["Learn"] == ["L1", "L2"]
|
||||
assert themes_by_name["Create"] == ["C1"]
|
||||
|
||||
|
||||
def test_suggested_prompts_no_understanding(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
test_user_id: str,
|
||||
) -> None:
|
||||
"""User with no understanding gets empty themes list."""
|
||||
_mock_get_business_understanding(mocker, return_value=None)
|
||||
|
||||
response = client.get("/suggested-prompts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"themes": []}
|
||||
|
||||
|
||||
def test_suggested_prompts_empty_prompts(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
test_user_id: str,
|
||||
) -> None:
|
||||
"""User with understanding but empty prompts gets empty themes list."""
|
||||
mock_understanding = MagicMock()
|
||||
mock_understanding.suggested_prompts = {}
|
||||
_mock_get_business_understanding(mocker, return_value=mock_understanding)
|
||||
|
||||
response = client.get("/suggested-prompts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"themes": []}
|
||||
|
||||
@@ -18,6 +18,7 @@ from prisma.errors import PrismaError
|
||||
|
||||
import backend.api.features.admin.credit_admin_routes
|
||||
import backend.api.features.admin.execution_analytics_routes
|
||||
import backend.api.features.admin.rate_limit_admin_routes
|
||||
import backend.api.features.admin.store_admin_routes
|
||||
import backend.api.features.builder
|
||||
import backend.api.features.builder.routes
|
||||
@@ -318,6 +319,11 @@ app.include_router(
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/executions",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.admin.rate_limit_admin_routes.router,
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/copilot",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.executions.review.routes.router,
|
||||
tags=["v2", "executions", "review"],
|
||||
|
||||
@@ -73,7 +73,7 @@ class ReadDiscordMessagesBlock(Block):
|
||||
id="df06086a-d5ac-4abb-9996-2ad0acb2eff7",
|
||||
input_schema=ReadDiscordMessagesBlock.Input, # Assign input schema
|
||||
output_schema=ReadDiscordMessagesBlock.Output, # Assign output schema
|
||||
description="Reads messages from a Discord channel using a bot token.",
|
||||
description="Reads new messages from a Discord channel using a bot token and triggers when a new message is posted",
|
||||
categories={BlockCategory.SOCIAL},
|
||||
test_input={
|
||||
"continuous_read": False,
|
||||
|
||||
@@ -28,9 +28,9 @@ class AgentInputBlock(Block):
|
||||
"""
|
||||
This block is used to provide input to the graph.
|
||||
|
||||
It takes in a value, name, description, default values list and bool to limit selection to default values.
|
||||
It takes in a value, name, and description.
|
||||
|
||||
It Outputs the value passed as input.
|
||||
It outputs the value passed as input.
|
||||
"""
|
||||
|
||||
class Input(BlockSchemaInput):
|
||||
@@ -47,12 +47,6 @@ class AgentInputBlock(Block):
|
||||
default=None,
|
||||
advanced=True,
|
||||
)
|
||||
placeholder_values: list = SchemaField(
|
||||
description="The placeholder values to be passed as input.",
|
||||
default_factory=list,
|
||||
advanced=True,
|
||||
hidden=True,
|
||||
)
|
||||
advanced: bool = SchemaField(
|
||||
description="Whether to show the input in the advanced section, if the field is not required.",
|
||||
default=False,
|
||||
@@ -65,10 +59,7 @@ class AgentInputBlock(Block):
|
||||
)
|
||||
|
||||
def generate_schema(self):
|
||||
schema = copy.deepcopy(self.get_field_schema("value"))
|
||||
if possible_values := self.placeholder_values:
|
||||
schema["enum"] = possible_values
|
||||
return schema
|
||||
return copy.deepcopy(self.get_field_schema("value"))
|
||||
|
||||
class Output(BlockSchema):
|
||||
# Use BlockSchema to avoid automatic error field for interface definition
|
||||
@@ -86,18 +77,16 @@ class AgentInputBlock(Block):
|
||||
"value": "Hello, World!",
|
||||
"name": "input_1",
|
||||
"description": "Example test input.",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
{
|
||||
"value": "Hello, World!",
|
||||
"value": 42,
|
||||
"name": "input_2",
|
||||
"description": "Example test input with placeholders.",
|
||||
"placeholder_values": ["Hello, World!"],
|
||||
"description": "Example numeric input.",
|
||||
},
|
||||
],
|
||||
"test_output": [
|
||||
("result", "Hello, World!"),
|
||||
("result", "Hello, World!"),
|
||||
("result", 42),
|
||||
],
|
||||
"categories": {BlockCategory.INPUT, BlockCategory.BASIC},
|
||||
"block_type": BlockType.INPUT,
|
||||
@@ -245,13 +234,11 @@ class AgentShortTextInputBlock(AgentInputBlock):
|
||||
"value": "Hello",
|
||||
"name": "short_text_1",
|
||||
"description": "Short text example 1",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
{
|
||||
"value": "Quick test",
|
||||
"name": "short_text_2",
|
||||
"description": "Short text example 2",
|
||||
"placeholder_values": ["Quick test", "Another option"],
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
@@ -285,13 +272,11 @@ class AgentLongTextInputBlock(AgentInputBlock):
|
||||
"value": "Lorem ipsum dolor sit amet...",
|
||||
"name": "long_text_1",
|
||||
"description": "Long text example 1",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
{
|
||||
"value": "Another multiline text input.",
|
||||
"name": "long_text_2",
|
||||
"description": "Long text example 2",
|
||||
"placeholder_values": ["Another multiline text input."],
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
@@ -325,13 +310,11 @@ class AgentNumberInputBlock(AgentInputBlock):
|
||||
"value": 42,
|
||||
"name": "number_input_1",
|
||||
"description": "Number example 1",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
{
|
||||
"value": 314,
|
||||
"name": "number_input_2",
|
||||
"description": "Number example 2",
|
||||
"placeholder_values": [314, 2718],
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
@@ -501,6 +484,12 @@ class AgentDropdownInputBlock(AgentInputBlock):
|
||||
title="Dropdown Options",
|
||||
)
|
||||
|
||||
def generate_schema(self):
|
||||
schema = super().generate_schema()
|
||||
if possible_values := self.placeholder_values:
|
||||
schema["enum"] = possible_values
|
||||
return schema
|
||||
|
||||
class Output(AgentInputBlock.Output):
|
||||
result: str = SchemaField(description="Selected dropdown value.")
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import pytest
|
||||
|
||||
from backend.blocks import get_blocks
|
||||
from backend.blocks._base import Block, BlockSchemaInput
|
||||
from backend.blocks.io import AgentDropdownInputBlock, AgentInputBlock
|
||||
from backend.data.graph import BaseGraph
|
||||
from backend.data.model import SchemaField
|
||||
from backend.util.test import execute_block_test
|
||||
|
||||
@@ -279,3 +281,66 @@ class TestAutoCredentialsFieldsValidation:
|
||||
assert "Duplicate auto_credentials kwarg_name 'credentials'" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_agent_input_block_ignores_legacy_placeholder_values():
|
||||
"""Verify AgentInputBlock.Input.model_construct tolerates extra placeholder_values
|
||||
for backward compatibility with existing agent JSON."""
|
||||
legacy_data = {
|
||||
"name": "url",
|
||||
"value": "",
|
||||
"description": "Enter a URL",
|
||||
"placeholder_values": ["https://example.com"],
|
||||
}
|
||||
instance = AgentInputBlock.Input.model_construct(**legacy_data)
|
||||
schema = instance.generate_schema()
|
||||
assert (
|
||||
"enum" not in schema
|
||||
), "AgentInputBlock should not produce enum from legacy placeholder_values"
|
||||
|
||||
|
||||
def test_dropdown_input_block_produces_enum():
|
||||
"""Verify AgentDropdownInputBlock.Input.generate_schema() produces enum."""
|
||||
options = ["Option A", "Option B"]
|
||||
instance = AgentDropdownInputBlock.Input.model_construct(
|
||||
name="choice", value=None, placeholder_values=options
|
||||
)
|
||||
schema = instance.generate_schema()
|
||||
assert schema.get("enum") == options
|
||||
|
||||
|
||||
def test_generate_schema_integration_legacy_placeholder_values():
|
||||
"""Test the full Graph._generate_schema path with legacy placeholder_values
|
||||
on AgentInputBlock — verifies no enum leaks through the graph loading path."""
|
||||
legacy_input_default = {
|
||||
"name": "url",
|
||||
"value": "",
|
||||
"description": "Enter a URL",
|
||||
"placeholder_values": ["https://example.com"],
|
||||
}
|
||||
result = BaseGraph._generate_schema(
|
||||
(AgentInputBlock.Input, legacy_input_default),
|
||||
)
|
||||
url_props = result["properties"]["url"]
|
||||
assert (
|
||||
"enum" not in url_props
|
||||
), "Graph schema should not contain enum from AgentInputBlock placeholder_values"
|
||||
|
||||
|
||||
def test_generate_schema_integration_dropdown_produces_enum():
|
||||
"""Test the full Graph._generate_schema path with AgentDropdownInputBlock
|
||||
— verifies enum IS produced for dropdown blocks."""
|
||||
dropdown_input_default = {
|
||||
"name": "color",
|
||||
"value": None,
|
||||
"placeholder_values": ["Red", "Green", "Blue"],
|
||||
}
|
||||
result = BaseGraph._generate_schema(
|
||||
(AgentDropdownInputBlock.Input, dropdown_input_default),
|
||||
)
|
||||
color_props = result["properties"]["color"]
|
||||
assert color_props.get("enum") == [
|
||||
"Red",
|
||||
"Green",
|
||||
"Blue",
|
||||
], "Graph schema should contain enum from AgentDropdownInputBlock"
|
||||
|
||||
@@ -91,6 +91,20 @@ class ChatConfig(BaseSettings):
|
||||
description="Max tokens per week, resets Monday 00:00 UTC (0 = unlimited)",
|
||||
)
|
||||
|
||||
# Cost (in credits / cents) to reset the daily rate limit using credits.
|
||||
# When a user hits their daily limit, they can spend this amount to reset
|
||||
# the daily counter and keep working. Set to 0 to disable the feature.
|
||||
rate_limit_reset_cost: int = Field(
|
||||
default=500,
|
||||
ge=0,
|
||||
description="Credit cost (in cents) for resetting the daily rate limit. 0 = disabled.",
|
||||
)
|
||||
max_daily_resets: int = Field(
|
||||
default=5,
|
||||
ge=0,
|
||||
description="Maximum number of credit-based rate limit resets per user per day. 0 = unlimited.",
|
||||
)
|
||||
|
||||
# Claude Agent SDK Configuration
|
||||
use_claude_agent_sdk: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -205,9 +205,10 @@ Important files (code, configs, outputs) should be saved to workspace to ensure
|
||||
### SDK tool-result files
|
||||
When tool outputs are large, the SDK truncates them and saves the full output to
|
||||
a local file under `~/.claude/projects/.../tool-results/`. To read these files,
|
||||
always use `read_file` or `Read` (NOT `read_workspace_file`).
|
||||
`read_workspace_file` reads from cloud workspace storage, where SDK
|
||||
tool-results are NOT stored.
|
||||
always use `Read` (NOT `bash_exec`, NOT `read_workspace_file`).
|
||||
These files are on the host filesystem — `bash_exec` runs in the sandbox and
|
||||
CANNOT access them. `read_workspace_file` reads from cloud workspace storage,
|
||||
where SDK tool-results are NOT stored.
|
||||
{_SHARED_TOOL_NOTES}{extra_notes}"""
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ class CoPilotUsageStatus(BaseModel):
|
||||
|
||||
daily: UsageWindow
|
||||
weekly: UsageWindow
|
||||
reset_cost: int = Field(
|
||||
default=0,
|
||||
description="Credit cost (in cents) to reset the daily limit. 0 = feature disabled.",
|
||||
)
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
@@ -61,6 +65,7 @@ async def get_usage_status(
|
||||
user_id: str,
|
||||
daily_token_limit: int,
|
||||
weekly_token_limit: int,
|
||||
rate_limit_reset_cost: int = 0,
|
||||
) -> CoPilotUsageStatus:
|
||||
"""Get current usage status for a user.
|
||||
|
||||
@@ -68,6 +73,7 @@ async def get_usage_status(
|
||||
user_id: The user's ID.
|
||||
daily_token_limit: Max tokens per day (0 = unlimited).
|
||||
weekly_token_limit: Max tokens per week (0 = unlimited).
|
||||
rate_limit_reset_cost: Credit cost (cents) to reset daily limit (0 = disabled).
|
||||
|
||||
Returns:
|
||||
CoPilotUsageStatus with current usage and limits.
|
||||
@@ -97,6 +103,7 @@ async def get_usage_status(
|
||||
limit=weekly_token_limit,
|
||||
resets_at=_weekly_reset_time(now=now),
|
||||
),
|
||||
reset_cost=rate_limit_reset_cost,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,6 +148,110 @@ async def check_rate_limit(
|
||||
raise RateLimitExceeded("weekly", _weekly_reset_time(now=now))
|
||||
|
||||
|
||||
async def reset_daily_usage(user_id: str, daily_token_limit: int = 0) -> bool:
|
||||
"""Reset a user's daily token usage counter in Redis.
|
||||
|
||||
Called after a user pays credits to extend their daily limit.
|
||||
Also reduces the weekly usage counter by ``daily_token_limit`` tokens
|
||||
(clamped to 0) so the user effectively gets one extra day's worth of
|
||||
weekly capacity.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID.
|
||||
daily_token_limit: The configured daily token limit. When positive,
|
||||
the weekly counter is reduced by this amount.
|
||||
|
||||
Fails open: returns False if Redis is unavailable (consistent with
|
||||
the fail-open design of this module).
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
|
||||
# Use a MULTI/EXEC transaction so that DELETE (daily) and DECRBY
|
||||
# (weekly) either both execute or neither does. This prevents the
|
||||
# scenario where the daily counter is cleared but the weekly
|
||||
# counter is not decremented — which would let the caller refund
|
||||
# credits even though the daily limit was already reset.
|
||||
d_key = _daily_key(user_id, now=now)
|
||||
w_key = _weekly_key(user_id, now=now) if daily_token_limit > 0 else None
|
||||
|
||||
pipe = redis.pipeline(transaction=True)
|
||||
pipe.delete(d_key)
|
||||
if w_key is not None:
|
||||
pipe.decrby(w_key, daily_token_limit)
|
||||
results = await pipe.execute()
|
||||
|
||||
# Clamp negative weekly counter to 0 (best-effort; not critical).
|
||||
if w_key is not None:
|
||||
new_val = results[1] # DECRBY result
|
||||
if new_val < 0:
|
||||
await redis.set(w_key, 0, keepttl=True)
|
||||
|
||||
logger.info("Reset daily usage for user %s", user_id[:8])
|
||||
return True
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
logger.warning("Redis unavailable for resetting daily usage")
|
||||
return False
|
||||
|
||||
|
||||
_RESET_LOCK_PREFIX = "copilot:reset_lock"
|
||||
_RESET_COUNT_PREFIX = "copilot:reset_count"
|
||||
|
||||
|
||||
async def acquire_reset_lock(user_id: str, ttl_seconds: int = 10) -> bool:
|
||||
"""Acquire a short-lived lock to serialize rate limit resets per user."""
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
key = f"{_RESET_LOCK_PREFIX}:{user_id}"
|
||||
return bool(await redis.set(key, "1", nx=True, ex=ttl_seconds))
|
||||
except (RedisError, ConnectionError, OSError) as exc:
|
||||
logger.warning("Redis unavailable for reset lock, rejecting reset: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def release_reset_lock(user_id: str) -> None:
|
||||
"""Release the per-user reset lock."""
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
await redis.delete(f"{_RESET_LOCK_PREFIX}:{user_id}")
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
pass # Lock will expire via TTL
|
||||
|
||||
|
||||
async def get_daily_reset_count(user_id: str) -> int | None:
|
||||
"""Get how many times the user has reset today.
|
||||
|
||||
Returns None when Redis is unavailable so callers can fail-closed
|
||||
for billed operations (as opposed to failing open for read-only
|
||||
rate-limit checks).
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
key = f"{_RESET_COUNT_PREFIX}:{user_id}:{now.strftime('%Y-%m-%d')}"
|
||||
val = await redis.get(key)
|
||||
return int(val or 0)
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
logger.warning("Redis unavailable for reading daily reset count")
|
||||
return None
|
||||
|
||||
|
||||
async def increment_daily_reset_count(user_id: str) -> None:
|
||||
"""Increment and track how many resets this user has done today."""
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
key = f"{_RESET_COUNT_PREFIX}:{user_id}:{now.strftime('%Y-%m-%d')}"
|
||||
pipe = redis.pipeline(transaction=True)
|
||||
pipe.incr(key)
|
||||
seconds_until_reset = int((_daily_reset_time(now=now) - now).total_seconds())
|
||||
pipe.expire(key, max(seconds_until_reset, 1))
|
||||
await pipe.execute()
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
logger.warning("Redis unavailable for tracking reset count")
|
||||
|
||||
|
||||
async def record_token_usage(
|
||||
user_id: str,
|
||||
prompt_tokens: int,
|
||||
@@ -231,6 +342,67 @@ async def record_token_usage(
|
||||
)
|
||||
|
||||
|
||||
async def get_global_rate_limits(
|
||||
user_id: str,
|
||||
config_daily: int,
|
||||
config_weekly: int,
|
||||
) -> tuple[int, int]:
|
||||
"""Resolve global rate limits from LaunchDarkly, falling back to config.
|
||||
|
||||
Args:
|
||||
user_id: User ID for LD flag evaluation context.
|
||||
config_daily: Fallback daily limit from ChatConfig.
|
||||
config_weekly: Fallback weekly limit from ChatConfig.
|
||||
|
||||
Returns:
|
||||
(daily_token_limit, weekly_token_limit) tuple.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency:
|
||||
# rate_limit -> feature_flag -> settings -> ... -> rate_limit
|
||||
from backend.util.feature_flag import Flag, get_feature_flag_value
|
||||
|
||||
daily_raw = await get_feature_flag_value(
|
||||
Flag.COPILOT_DAILY_TOKEN_LIMIT.value, user_id, config_daily
|
||||
)
|
||||
weekly_raw = await get_feature_flag_value(
|
||||
Flag.COPILOT_WEEKLY_TOKEN_LIMIT.value, user_id, config_weekly
|
||||
)
|
||||
try:
|
||||
daily = max(0, int(daily_raw))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid LD value for daily token limit: %r", daily_raw)
|
||||
daily = config_daily
|
||||
try:
|
||||
weekly = max(0, int(weekly_raw))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid LD value for weekly token limit: %r", weekly_raw)
|
||||
weekly = config_weekly
|
||||
return daily, weekly
|
||||
|
||||
|
||||
async def reset_user_usage(user_id: str, *, reset_weekly: bool = False) -> None:
|
||||
"""Reset a user's usage counters.
|
||||
|
||||
Always deletes the daily Redis key. When *reset_weekly* is ``True``,
|
||||
the weekly key is deleted as well.
|
||||
|
||||
Unlike read paths (``get_usage_status``, ``check_rate_limit``) which
|
||||
fail-open on Redis errors, resets intentionally re-raise so the caller
|
||||
knows the operation did not succeed. A silent failure here would leave
|
||||
the admin believing the counters were zeroed when they were not.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
keys_to_delete = [_daily_key(user_id, now=now)]
|
||||
if reset_weekly:
|
||||
keys_to_delete.append(_weekly_key(user_id, now=now))
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
await redis.delete(*keys_to_delete)
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
logger.warning("Redis unavailable for resetting user usage")
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ from .rate_limit import (
|
||||
check_rate_limit,
|
||||
get_usage_status,
|
||||
record_token_usage,
|
||||
reset_daily_usage,
|
||||
)
|
||||
|
||||
_USER = "test-user-rl"
|
||||
@@ -332,3 +333,91 @@ class TestRecordTokenUsage:
|
||||
):
|
||||
# Should not raise — fail-open
|
||||
await record_token_usage(_USER, prompt_tokens=100, completion_tokens=50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_daily_usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResetDailyUsage:
|
||||
@staticmethod
|
||||
def _make_pipeline_mock(decrby_result: int = 0) -> MagicMock:
|
||||
"""Create a pipeline mock that returns [delete_result, decrby_result]."""
|
||||
pipe = MagicMock()
|
||||
pipe.execute = AsyncMock(return_value=[1, decrby_result])
|
||||
return pipe
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_daily_key(self):
|
||||
mock_pipe = self._make_pipeline_mock(decrby_result=0)
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.pipeline = lambda **_kw: mock_pipe
|
||||
|
||||
with patch(
|
||||
"backend.copilot.rate_limit.get_redis_async",
|
||||
return_value=mock_redis,
|
||||
):
|
||||
result = await reset_daily_usage(_USER, daily_token_limit=10000)
|
||||
|
||||
assert result is True
|
||||
mock_pipe.delete.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reduces_weekly_usage_via_decrby(self):
|
||||
"""Weekly counter should be reduced via DECRBY in the pipeline."""
|
||||
mock_pipe = self._make_pipeline_mock(decrby_result=35000)
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.pipeline = lambda **_kw: mock_pipe
|
||||
|
||||
with patch(
|
||||
"backend.copilot.rate_limit.get_redis_async",
|
||||
return_value=mock_redis,
|
||||
):
|
||||
await reset_daily_usage(_USER, daily_token_limit=10000)
|
||||
|
||||
mock_pipe.decrby.assert_called_once()
|
||||
mock_redis.set.assert_not_called() # 35000 > 0, no clamp needed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clamps_negative_weekly_to_zero(self):
|
||||
"""If DECRBY goes negative, SET to 0 (outside the pipeline)."""
|
||||
mock_pipe = self._make_pipeline_mock(decrby_result=-5000)
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.pipeline = lambda **_kw: mock_pipe
|
||||
|
||||
with patch(
|
||||
"backend.copilot.rate_limit.get_redis_async",
|
||||
return_value=mock_redis,
|
||||
):
|
||||
await reset_daily_usage(_USER, daily_token_limit=10000)
|
||||
|
||||
mock_pipe.decrby.assert_called_once()
|
||||
mock_redis.set.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_weekly_reduction_when_daily_limit_zero(self):
|
||||
"""When daily_token_limit is 0, weekly counter should not be touched."""
|
||||
mock_pipe = self._make_pipeline_mock()
|
||||
mock_pipe.execute = AsyncMock(return_value=[1]) # only delete result
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.pipeline = lambda **_kw: mock_pipe
|
||||
|
||||
with patch(
|
||||
"backend.copilot.rate_limit.get_redis_async",
|
||||
return_value=mock_redis,
|
||||
):
|
||||
await reset_daily_usage(_USER, daily_token_limit=0)
|
||||
|
||||
mock_pipe.delete.assert_called_once()
|
||||
mock_pipe.decrby.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_redis_unavailable(self):
|
||||
with patch(
|
||||
"backend.copilot.rate_limit.get_redis_async",
|
||||
side_effect=ConnectionError("Redis down"),
|
||||
):
|
||||
result = await reset_daily_usage(_USER, daily_token_limit=10000)
|
||||
|
||||
assert result is False
|
||||
|
||||
294
autogpt_platform/backend/backend/copilot/reset_usage_test.py
Normal file
294
autogpt_platform/backend/backend/copilot/reset_usage_test.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Unit tests for the POST /usage/reset endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.api.features.chat.routes import reset_copilot_usage
|
||||
from backend.copilot.rate_limit import CoPilotUsageStatus, UsageWindow
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
|
||||
|
||||
# Minimal config mock matching ChatConfig fields used by the endpoint.
|
||||
def _make_config(
|
||||
rate_limit_reset_cost: int = 500,
|
||||
daily_token_limit: int = 2_500_000,
|
||||
weekly_token_limit: int = 12_500_000,
|
||||
max_daily_resets: int = 5,
|
||||
):
|
||||
cfg = MagicMock()
|
||||
cfg.rate_limit_reset_cost = rate_limit_reset_cost
|
||||
cfg.daily_token_limit = daily_token_limit
|
||||
cfg.weekly_token_limit = weekly_token_limit
|
||||
cfg.max_daily_resets = max_daily_resets
|
||||
return cfg
|
||||
|
||||
|
||||
def _usage(daily_used: int = 3_000_000, daily_limit: int = 2_500_000):
|
||||
return CoPilotUsageStatus(
|
||||
daily=UsageWindow(
|
||||
used=daily_used,
|
||||
limit=daily_limit,
|
||||
resets_at=datetime.now(UTC) + timedelta(hours=6),
|
||||
),
|
||||
weekly=UsageWindow(
|
||||
used=5_000_000,
|
||||
limit=12_500_000,
|
||||
resets_at=datetime.now(UTC) + timedelta(days=3),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_MODULE = "backend.api.features.chat.routes"
|
||||
|
||||
|
||||
def _mock_settings(enable_credit: bool = True):
|
||||
"""Return a mock Settings object with the given enable_credit flag."""
|
||||
mock = MagicMock()
|
||||
mock.config.enable_credit = enable_credit
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestResetCopilotUsage:
|
||||
async def test_feature_disabled_returns_400(self):
|
||||
"""When rate_limit_reset_cost=0, endpoint returns 400."""
|
||||
|
||||
with patch(f"{_MODULE}.config", _make_config(rate_limit_reset_cost=0)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "not available" in exc_info.value.detail
|
||||
|
||||
async def test_no_daily_limit_returns_400(self):
|
||||
"""When daily_token_limit=0 (unlimited), endpoint returns 400."""
|
||||
|
||||
with (
|
||||
patch(f"{_MODULE}.config", _make_config(daily_token_limit=0)),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "nothing to reset" in exc_info.value.detail.lower()
|
||||
|
||||
async def test_not_at_limit_returns_400(self):
|
||||
"""When user hasn't hit their daily limit, returns 400."""
|
||||
|
||||
cfg = _make_config()
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()) as mock_release,
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(return_value=_usage(daily_used=1_000_000)),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "not reached" in exc_info.value.detail
|
||||
mock_release.assert_awaited_once()
|
||||
|
||||
async def test_insufficient_credits_returns_402(self):
|
||||
"""When user doesn't have enough credits, returns 402."""
|
||||
|
||||
mock_credit_model = AsyncMock()
|
||||
mock_credit_model.spend_credits.side_effect = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id="user-1",
|
||||
balance=50,
|
||||
amount=200,
|
||||
)
|
||||
|
||||
cfg = _make_config()
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()) as mock_release,
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(return_value=_usage()),
|
||||
),
|
||||
patch(
|
||||
f"{_MODULE}.get_user_credit_model",
|
||||
AsyncMock(return_value=mock_credit_model),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 402
|
||||
mock_release.assert_awaited_once()
|
||||
|
||||
async def test_happy_path(self):
|
||||
"""Successful reset: charges credits, resets usage, returns response."""
|
||||
|
||||
mock_credit_model = AsyncMock()
|
||||
mock_credit_model.spend_credits.return_value = 1500 # remaining balance
|
||||
|
||||
cfg = _make_config()
|
||||
updated_usage = _usage(daily_used=0)
|
||||
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()),
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(side_effect=[_usage(), updated_usage]),
|
||||
),
|
||||
patch(
|
||||
f"{_MODULE}.get_user_credit_model",
|
||||
AsyncMock(return_value=mock_credit_model),
|
||||
),
|
||||
patch(
|
||||
f"{_MODULE}.reset_daily_usage", AsyncMock(return_value=True)
|
||||
) as mock_reset,
|
||||
patch(f"{_MODULE}.increment_daily_reset_count", AsyncMock()) as mock_incr,
|
||||
):
|
||||
result = await reset_copilot_usage(user_id="user-1")
|
||||
assert result.success is True
|
||||
assert result.credits_charged == 500
|
||||
assert result.remaining_balance == 1500
|
||||
mock_reset.assert_awaited_once()
|
||||
mock_incr.assert_awaited_once()
|
||||
|
||||
async def test_max_daily_resets_exceeded(self):
|
||||
"""When user has exhausted daily resets, returns 429."""
|
||||
|
||||
cfg = _make_config(max_daily_resets=3)
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=3)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
async def test_credit_system_disabled_returns_400(self):
|
||||
"""When enable_credit=False, endpoint returns 400."""
|
||||
|
||||
with (
|
||||
patch(f"{_MODULE}.config", _make_config()),
|
||||
patch(f"{_MODULE}.settings", _mock_settings(enable_credit=False)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "credit system is disabled" in exc_info.value.detail.lower()
|
||||
|
||||
async def test_weekly_limit_exhausted_returns_400(self):
|
||||
"""When the weekly limit is also exhausted, resetting daily won't help."""
|
||||
|
||||
cfg = _make_config()
|
||||
weekly_exhausted = CoPilotUsageStatus(
|
||||
daily=UsageWindow(
|
||||
used=3_000_000,
|
||||
limit=2_500_000,
|
||||
resets_at=datetime.now(UTC) + timedelta(hours=6),
|
||||
),
|
||||
weekly=UsageWindow(
|
||||
used=12_500_000,
|
||||
limit=12_500_000,
|
||||
resets_at=datetime.now(UTC) + timedelta(days=3),
|
||||
),
|
||||
)
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()) as mock_release,
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(return_value=weekly_exhausted),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "weekly" in exc_info.value.detail.lower()
|
||||
mock_release.assert_awaited_once()
|
||||
|
||||
async def test_redis_failure_for_reset_count_returns_503(self):
|
||||
"""When Redis is unavailable for get_daily_reset_count, returns 503."""
|
||||
|
||||
with (
|
||||
patch(f"{_MODULE}.config", _make_config()),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=None)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "verify" in exc_info.value.detail.lower()
|
||||
|
||||
async def test_redis_reset_failure_refunds_credits(self):
|
||||
"""When reset_daily_usage fails, credits are refunded and 503 returned."""
|
||||
|
||||
mock_credit_model = AsyncMock()
|
||||
mock_credit_model.spend_credits.return_value = 1500
|
||||
|
||||
cfg = _make_config()
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()),
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(return_value=_usage()),
|
||||
),
|
||||
patch(
|
||||
f"{_MODULE}.get_user_credit_model",
|
||||
AsyncMock(return_value=mock_credit_model),
|
||||
),
|
||||
patch(f"{_MODULE}.reset_daily_usage", AsyncMock(return_value=False)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "not been charged" in exc_info.value.detail
|
||||
mock_credit_model.top_up_credits.assert_awaited_once()
|
||||
|
||||
async def test_redis_reset_failure_refund_also_fails(self):
|
||||
"""When both reset and refund fail, error message reflects the truth."""
|
||||
|
||||
mock_credit_model = AsyncMock()
|
||||
mock_credit_model.spend_credits.return_value = 1500
|
||||
mock_credit_model.top_up_credits.side_effect = RuntimeError("db down")
|
||||
|
||||
cfg = _make_config()
|
||||
with (
|
||||
patch(f"{_MODULE}.config", cfg),
|
||||
patch(f"{_MODULE}.settings", _mock_settings()),
|
||||
patch(f"{_MODULE}.get_daily_reset_count", AsyncMock(return_value=0)),
|
||||
patch(f"{_MODULE}.acquire_reset_lock", AsyncMock(return_value=True)),
|
||||
patch(f"{_MODULE}.release_reset_lock", AsyncMock()),
|
||||
patch(
|
||||
f"{_MODULE}.get_usage_status",
|
||||
AsyncMock(return_value=_usage()),
|
||||
),
|
||||
patch(
|
||||
f"{_MODULE}.get_user_credit_model",
|
||||
AsyncMock(return_value=mock_credit_model),
|
||||
),
|
||||
patch(f"{_MODULE}.reset_daily_usage", AsyncMock(return_value=False)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reset_copilot_usage(user_id="user-1")
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "contact support" in exc_info.value.detail.lower()
|
||||
@@ -67,9 +67,17 @@ These define the agent's interface — what it accepts and what it produces.
|
||||
**AgentInputBlock** (ID: `c0a8e994-ebf1-4a9c-a4d8-89d09c86741b`):
|
||||
- Defines a user-facing input field on the agent
|
||||
- Required `input_default` fields: `name` (str), `value` (default: null)
|
||||
- Optional: `title`, `description`, `placeholder_values` (for dropdowns)
|
||||
- Optional: `title`, `description`
|
||||
- Output: `result` — the user-provided value at runtime
|
||||
- Create one AgentInputBlock per distinct input the agent needs
|
||||
- For dropdown/select inputs, use **AgentDropdownInputBlock** instead (see below)
|
||||
|
||||
**AgentDropdownInputBlock** (ID: `655d6fdf-a334-421c-b733-520549c07cd1`):
|
||||
- Specialized input block that presents a dropdown/select to the user
|
||||
- Required `input_default` fields: `name` (str), `placeholder_values` (list of options, must have at least one)
|
||||
- Optional: `title`, `description`, `value` (default selection)
|
||||
- Output: `result` — the user-selected value at runtime
|
||||
- Use this instead of AgentInputBlock when the user should pick from a fixed set of options
|
||||
|
||||
**AgentOutputBlock** (ID: `363ae599-353e-4804-937e-b2ee3cef3da4`):
|
||||
- Defines a user-facing output displayed after the agent runs
|
||||
|
||||
@@ -102,7 +102,6 @@ async def setup_test_data(server):
|
||||
"value": "",
|
||||
"advanced": False,
|
||||
"description": "Test input field",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
metadata={"position": {"x": 0, "y": 0}},
|
||||
)
|
||||
@@ -242,7 +241,6 @@ async def setup_llm_test_data(server):
|
||||
"value": "",
|
||||
"advanced": False,
|
||||
"description": "Prompt for the LLM",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
metadata={"position": {"x": 0, "y": 0}},
|
||||
)
|
||||
@@ -396,7 +394,6 @@ async def setup_firecrawl_test_data(server):
|
||||
"value": "",
|
||||
"advanced": False,
|
||||
"description": "URL for Firecrawl to scrape",
|
||||
"placeholder_values": [],
|
||||
},
|
||||
metadata={"position": {"x": 0, "y": 0}},
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from backend.data.dynamic_fields import DICT_SPLIT
|
||||
|
||||
from .helpers import (
|
||||
AGENT_EXECUTOR_BLOCK_ID,
|
||||
MCP_TOOL_BLOCK_ID,
|
||||
@@ -1536,8 +1538,8 @@ class AgentFixer:
|
||||
for link in links:
|
||||
sink_name = link.get("sink_name", "")
|
||||
|
||||
if "_#_" in sink_name:
|
||||
parent, child = sink_name.split("_#_", 1)
|
||||
if DICT_SPLIT in sink_name:
|
||||
parent, child = sink_name.split(DICT_SPLIT, 1)
|
||||
|
||||
# Check if child is a numeric index (invalid for _#_ notation)
|
||||
if child.isdigit():
|
||||
|
||||
@@ -4,6 +4,8 @@ import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from backend.data.dynamic_fields import DICT_SPLIT
|
||||
|
||||
from .blocks import get_blocks_as_dicts
|
||||
|
||||
__all__ = [
|
||||
@@ -51,8 +53,8 @@ def generate_uuid() -> str:
|
||||
|
||||
def get_defined_property_type(schema: dict[str, Any], name: str) -> str | None:
|
||||
"""Get property type from a schema, handling nested `_#_` notation."""
|
||||
if "_#_" in name:
|
||||
parent, child = name.split("_#_", 1)
|
||||
if DICT_SPLIT in name:
|
||||
parent, child = name.split(DICT_SPLIT, 1)
|
||||
parent_schema = schema.get(parent, {})
|
||||
if "properties" in parent_schema and isinstance(
|
||||
parent_schema["properties"], dict
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from backend.data.dynamic_fields import DICT_SPLIT
|
||||
|
||||
from .helpers import (
|
||||
AGENT_EXECUTOR_BLOCK_ID,
|
||||
AGENT_INPUT_BLOCK_ID,
|
||||
@@ -256,95 +258,6 @@ class AgentValidator:
|
||||
|
||||
return valid
|
||||
|
||||
def validate_nested_sink_links(
|
||||
self,
|
||||
agent: AgentDict,
|
||||
blocks: list[dict[str, Any]],
|
||||
node_lookup: dict[str, dict[str, Any]] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Validate nested sink links (links with _#_ notation).
|
||||
Returns True if all nested links are valid, False otherwise.
|
||||
"""
|
||||
valid = True
|
||||
block_input_schemas = {
|
||||
block.get("id", ""): block.get("inputSchema", {}).get("properties", {})
|
||||
for block in blocks
|
||||
}
|
||||
block_names = {
|
||||
block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
|
||||
}
|
||||
if node_lookup is None:
|
||||
node_lookup = self._build_node_lookup(agent)
|
||||
|
||||
for link in agent.get("links", []):
|
||||
sink_name = link.get("sink_name", "")
|
||||
sink_id = link.get("sink_id")
|
||||
|
||||
if not sink_name or not sink_id:
|
||||
continue
|
||||
|
||||
if "_#_" in sink_name:
|
||||
parent, child = sink_name.split("_#_", 1)
|
||||
|
||||
sink_node = node_lookup.get(sink_id)
|
||||
if not sink_node:
|
||||
continue
|
||||
|
||||
block_id = sink_node.get("block_id")
|
||||
input_props = block_input_schemas.get(block_id, {})
|
||||
|
||||
parent_schema = input_props.get(parent)
|
||||
if not parent_schema:
|
||||
block_name = block_names.get(block_id, "Unknown Block")
|
||||
self.add_error(
|
||||
f"Invalid nested sink link '{sink_name}' for "
|
||||
f"node '{sink_id}' (block "
|
||||
f"'{block_name}' - {block_id}): Parent property "
|
||||
f"'{parent}' does not exist in the block's "
|
||||
f"input schema."
|
||||
)
|
||||
valid = False
|
||||
continue
|
||||
|
||||
# Check if additionalProperties is allowed either directly
|
||||
# or via anyOf
|
||||
allows_additional_properties = parent_schema.get(
|
||||
"additionalProperties", False
|
||||
)
|
||||
|
||||
# Check anyOf for additionalProperties
|
||||
if not allows_additional_properties and "anyOf" in parent_schema:
|
||||
any_of_schemas = parent_schema.get("anyOf", [])
|
||||
if isinstance(any_of_schemas, list):
|
||||
for schema_option in any_of_schemas:
|
||||
if isinstance(schema_option, dict) and schema_option.get(
|
||||
"additionalProperties"
|
||||
):
|
||||
allows_additional_properties = True
|
||||
break
|
||||
|
||||
if not allows_additional_properties:
|
||||
if not (
|
||||
isinstance(parent_schema, dict)
|
||||
and "properties" in parent_schema
|
||||
and isinstance(parent_schema["properties"], dict)
|
||||
and child in parent_schema["properties"]
|
||||
):
|
||||
block_name = block_names.get(block_id, "Unknown Block")
|
||||
self.add_error(
|
||||
f"Invalid nested sink link '{sink_name}' "
|
||||
f"for node '{link.get('sink_id', '')}' (block "
|
||||
f"'{block_name}' - {block_id}): Child "
|
||||
f"property '{child}' does not exist in "
|
||||
f"parent '{parent}' schema. Available "
|
||||
f"properties: "
|
||||
f"{list(parent_schema.get('properties', {}).keys())}"
|
||||
)
|
||||
valid = False
|
||||
|
||||
return valid
|
||||
|
||||
def validate_prompt_double_curly_braces_spaces(self, agent: AgentDict) -> bool:
|
||||
"""
|
||||
Validate that prompt parameters do not contain spaces in double curly
|
||||
@@ -471,8 +384,8 @@ class AgentValidator:
|
||||
output_props = block_output_schemas.get(block_id, {})
|
||||
|
||||
# Handle nested source names (with _#_ notation)
|
||||
if "_#_" in source_name:
|
||||
parent, child = source_name.split("_#_", 1)
|
||||
if DICT_SPLIT in source_name:
|
||||
parent, child = source_name.split(DICT_SPLIT, 1)
|
||||
|
||||
parent_schema = output_props.get(parent)
|
||||
if not parent_schema:
|
||||
@@ -553,6 +466,195 @@ class AgentValidator:
|
||||
|
||||
return valid
|
||||
|
||||
def validate_sink_input_existence(
|
||||
self,
|
||||
agent: AgentDict,
|
||||
blocks: list[dict[str, Any]],
|
||||
node_lookup: dict[str, dict[str, Any]] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that all sink_names in links and input_default keys in nodes
|
||||
exist in the corresponding block's input schema.
|
||||
|
||||
Checks that for each link the sink_name references a valid input
|
||||
property in the sink block's inputSchema, and that every key in a
|
||||
node's input_default is a recognised input property. Also handles
|
||||
nested inputs with _#_ notation and dynamic schemas for
|
||||
AgentExecutorBlock.
|
||||
|
||||
Args:
|
||||
agent: The agent dictionary to validate
|
||||
blocks: List of available blocks with their schemas
|
||||
node_lookup: Optional pre-built node-id → node dict
|
||||
|
||||
Returns:
|
||||
True if all sink input fields exist, False otherwise
|
||||
"""
|
||||
valid = True
|
||||
|
||||
block_input_schemas = {
|
||||
block.get("id", ""): block.get("inputSchema", {}).get("properties", {})
|
||||
for block in blocks
|
||||
}
|
||||
block_names = {
|
||||
block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
|
||||
}
|
||||
if node_lookup is None:
|
||||
node_lookup = self._build_node_lookup(agent)
|
||||
|
||||
def get_input_props(node: dict[str, Any]) -> dict[str, Any]:
|
||||
block_id = node.get("block_id", "")
|
||||
if block_id == AGENT_EXECUTOR_BLOCK_ID:
|
||||
input_default = node.get("input_default", {})
|
||||
dynamic_input_schema = input_default.get("input_schema", {})
|
||||
if not isinstance(dynamic_input_schema, dict):
|
||||
dynamic_input_schema = {}
|
||||
dynamic_props = dynamic_input_schema.get("properties", {})
|
||||
if not isinstance(dynamic_props, dict):
|
||||
dynamic_props = {}
|
||||
static_props = block_input_schemas.get(block_id, {})
|
||||
return {**static_props, **dynamic_props}
|
||||
return block_input_schemas.get(block_id, {})
|
||||
|
||||
def check_nested_input(
|
||||
input_props: dict[str, Any],
|
||||
field_name: str,
|
||||
context: str,
|
||||
block_name: str,
|
||||
block_id: str,
|
||||
) -> bool:
|
||||
parent, child = field_name.split(DICT_SPLIT, 1)
|
||||
parent_schema = input_props.get(parent)
|
||||
if not parent_schema:
|
||||
self.add_error(
|
||||
f"{context}: Parent property '{parent}' does not "
|
||||
f"exist in block '{block_name}' ({block_id}) input "
|
||||
f"schema."
|
||||
)
|
||||
return False
|
||||
|
||||
allows_additional = parent_schema.get("additionalProperties", False)
|
||||
# Only anyOf is checked here because Pydantic's JSON schema
|
||||
# emits optional/union fields via anyOf. allOf and oneOf are
|
||||
# not currently used by any block's dict-typed inputs, so
|
||||
# false positives from them are not a concern in practice.
|
||||
if not allows_additional and "anyOf" in parent_schema:
|
||||
for schema_option in parent_schema.get("anyOf", []):
|
||||
if not isinstance(schema_option, dict):
|
||||
continue
|
||||
if schema_option.get("additionalProperties"):
|
||||
allows_additional = True
|
||||
break
|
||||
items_schema = schema_option.get("items")
|
||||
if isinstance(items_schema, dict) and items_schema.get(
|
||||
"additionalProperties"
|
||||
):
|
||||
allows_additional = True
|
||||
break
|
||||
|
||||
if not allows_additional:
|
||||
if not (
|
||||
isinstance(parent_schema, dict)
|
||||
and "properties" in parent_schema
|
||||
and isinstance(parent_schema["properties"], dict)
|
||||
and child in parent_schema["properties"]
|
||||
):
|
||||
available = (
|
||||
list(parent_schema.get("properties", {}).keys())
|
||||
if isinstance(parent_schema, dict)
|
||||
else []
|
||||
)
|
||||
self.add_error(
|
||||
f"{context}: Child property '{child}' does not "
|
||||
f"exist in parent '{parent}' of block "
|
||||
f"'{block_name}' ({block_id}) input schema. "
|
||||
f"Available properties: {available}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
for link in agent.get("links", []):
|
||||
sink_id = link.get("sink_id")
|
||||
sink_name = link.get("sink_name", "")
|
||||
link_id = link.get("id", "Unknown")
|
||||
|
||||
if not sink_name:
|
||||
# Missing sink_name is caught by validate_data_type_compatibility
|
||||
continue
|
||||
|
||||
sink_node = node_lookup.get(sink_id)
|
||||
if not sink_node:
|
||||
# Already caught by validate_link_node_references
|
||||
continue
|
||||
|
||||
block_id = sink_node.get("block_id", "")
|
||||
block_name = block_names.get(block_id, "Unknown Block")
|
||||
input_props = get_input_props(sink_node)
|
||||
|
||||
context = (
|
||||
f"Invalid sink input field '{sink_name}' in link "
|
||||
f"'{link_id}' to node '{sink_id}'"
|
||||
)
|
||||
|
||||
if DICT_SPLIT in sink_name:
|
||||
if not check_nested_input(
|
||||
input_props, sink_name, context, block_name, block_id
|
||||
):
|
||||
valid = False
|
||||
else:
|
||||
if sink_name not in input_props:
|
||||
available_inputs = list(input_props.keys())
|
||||
self.add_error(
|
||||
f"{context} (block '{block_name}' - {block_id}): "
|
||||
f"Input property '{sink_name}' does not exist in "
|
||||
f"the block's input schema. "
|
||||
f"Available inputs: {available_inputs}"
|
||||
)
|
||||
valid = False
|
||||
|
||||
for node in agent.get("nodes", []):
|
||||
node_id = node.get("id")
|
||||
block_id = node.get("block_id", "")
|
||||
block_name = block_names.get(block_id, "Unknown Block")
|
||||
input_default = node.get("input_default", {})
|
||||
|
||||
if not isinstance(input_default, dict) or not input_default:
|
||||
continue
|
||||
|
||||
if (
|
||||
block_id not in block_input_schemas
|
||||
and block_id != AGENT_EXECUTOR_BLOCK_ID
|
||||
):
|
||||
continue
|
||||
|
||||
input_props = get_input_props(node)
|
||||
|
||||
for key in input_default:
|
||||
if key == "credentials":
|
||||
continue
|
||||
|
||||
context = (
|
||||
f"Node '{node_id}' (block '{block_name}' - {block_id}) "
|
||||
f"has unknown input_default key '{key}'"
|
||||
)
|
||||
|
||||
if DICT_SPLIT in key:
|
||||
if not check_nested_input(
|
||||
input_props, key, context, block_name, block_id
|
||||
):
|
||||
valid = False
|
||||
else:
|
||||
if key not in input_props:
|
||||
available_inputs = list(input_props.keys())
|
||||
self.add_error(
|
||||
f"{context} which does not exist in the "
|
||||
f"block's input schema. "
|
||||
f"Available inputs: {available_inputs}"
|
||||
)
|
||||
valid = False
|
||||
|
||||
return valid
|
||||
|
||||
def validate_io_blocks(self, agent: AgentDict) -> bool:
|
||||
"""
|
||||
Validate that the agent has at least one AgentInputBlock and one
|
||||
@@ -998,14 +1100,14 @@ class AgentValidator:
|
||||
"Data type compatibility",
|
||||
self.validate_data_type_compatibility(agent, blocks, node_lookup),
|
||||
),
|
||||
(
|
||||
"Nested sink links",
|
||||
self.validate_nested_sink_links(agent, blocks, node_lookup),
|
||||
),
|
||||
(
|
||||
"Source output existence",
|
||||
self.validate_source_output_existence(agent, blocks, node_lookup),
|
||||
),
|
||||
(
|
||||
"Sink input existence",
|
||||
self.validate_sink_input_existence(agent, blocks, node_lookup),
|
||||
),
|
||||
(
|
||||
"Prompt double curly braces spaces",
|
||||
self.validate_prompt_double_curly_braces_spaces(agent),
|
||||
|
||||
@@ -331,43 +331,6 @@ class TestValidatePromptDoubleCurlyBracesSpaces:
|
||||
assert any("spaces" in e for e in v.errors)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# validate_nested_sink_links
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestValidateNestedSinkLinks:
|
||||
def test_valid_nested_link_passes(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {"key": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(sink_id="n1", sink_name="config_#_key", source_id="n2")
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_nested_sink_links(agent, [block]) is True
|
||||
|
||||
def test_invalid_parent_fails(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(block_id="b1")
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(sink_id="n1", sink_name="nonexistent_#_key", source_id="n2")
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_nested_sink_links(agent, [block]) is False
|
||||
assert any("does not exist" in e for e in v.errors)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# validate_agent_executor_block_schemas
|
||||
# ============================================================================
|
||||
@@ -595,11 +558,28 @@ class TestValidate:
|
||||
input_block = _make_block(
|
||||
block_id=AGENT_INPUT_BLOCK_ID,
|
||||
name="AgentInputBlock",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
output_schema={"properties": {"result": {}}},
|
||||
)
|
||||
output_block = _make_block(
|
||||
block_id=AGENT_OUTPUT_BLOCK_ID,
|
||||
name="AgentOutputBlock",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
input_node = _make_node(
|
||||
node_id="n-in",
|
||||
@@ -650,6 +630,201 @@ class TestValidate:
|
||||
assert "AgentOutputBlock" in error_message
|
||||
|
||||
|
||||
class TestValidateSinkInputExistence:
|
||||
"""Tests for validate_sink_input_existence."""
|
||||
|
||||
def test_valid_sink_name_passes(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
|
||||
)
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(
|
||||
source_id="src", source_name="out", sink_id="n1", sink_name="url"
|
||||
)
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is True
|
||||
|
||||
def test_invalid_sink_name_fails(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
|
||||
)
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(
|
||||
source_id="src", source_name="out", sink_id="n1", sink_name="nonexistent"
|
||||
)
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is False
|
||||
assert any("nonexistent" in e for e in v.errors)
|
||||
|
||||
def test_valid_nested_link_passes(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {"key": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(
|
||||
source_id="src",
|
||||
source_name="out",
|
||||
sink_id="n1",
|
||||
sink_name="config_#_key",
|
||||
)
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is True
|
||||
|
||||
def test_invalid_nested_child_fails(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {"key": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
node = _make_node(node_id="n1", block_id="b1")
|
||||
link = _make_link(
|
||||
source_id="src",
|
||||
source_name="out",
|
||||
sink_id="n1",
|
||||
sink_name="config_#_missing",
|
||||
)
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is False
|
||||
|
||||
def test_unknown_input_default_key_fails(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
|
||||
)
|
||||
node = _make_node(
|
||||
node_id="n1", block_id="b1", input_default={"nonexistent_key": "value"}
|
||||
)
|
||||
agent = _make_agent(nodes=[node])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is False
|
||||
assert any("nonexistent_key" in e for e in v.errors)
|
||||
|
||||
def test_credentials_key_skipped(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={"properties": {"url": {"type": "string"}}, "required": []},
|
||||
)
|
||||
node = _make_node(
|
||||
node_id="n1",
|
||||
block_id="b1",
|
||||
input_default={
|
||||
"url": "http://example.com",
|
||||
"credentials": {"api_key": "x"},
|
||||
},
|
||||
)
|
||||
agent = _make_agent(nodes=[node])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is True
|
||||
|
||||
def test_agent_executor_dynamic_schema_passes(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id=AGENT_EXECUTOR_BLOCK_ID,
|
||||
input_schema={
|
||||
"properties": {
|
||||
"graph_id": {"type": "string"},
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
"required": ["graph_id"],
|
||||
},
|
||||
)
|
||||
node = _make_node(
|
||||
node_id="n1",
|
||||
block_id=AGENT_EXECUTOR_BLOCK_ID,
|
||||
input_default={
|
||||
"graph_id": "abc",
|
||||
"input_schema": {
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
link = _make_link(
|
||||
source_id="src",
|
||||
source_name="out",
|
||||
sink_id="n1",
|
||||
sink_name="query",
|
||||
)
|
||||
agent = _make_agent(nodes=[node], links=[link])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is True
|
||||
|
||||
def test_input_default_nested_invalid_child_fails(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {"key": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
node = _make_node(
|
||||
node_id="n1",
|
||||
block_id="b1",
|
||||
input_default={"config_#_invalid_child": "value"},
|
||||
)
|
||||
agent = _make_agent(nodes=[node])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is False
|
||||
assert any("invalid_child" in e for e in v.errors)
|
||||
|
||||
def test_input_default_nested_valid_child_passes(self):
|
||||
v = AgentValidator()
|
||||
block = _make_block(
|
||||
block_id="b1",
|
||||
input_schema={
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {"key": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
node = _make_node(
|
||||
node_id="n1",
|
||||
block_id="b1",
|
||||
input_default={"config_#_key": "value"},
|
||||
)
|
||||
agent = _make_agent(nodes=[node])
|
||||
|
||||
assert v.validate_sink_input_existence(agent, [block]) is True
|
||||
|
||||
|
||||
class TestValidateMCPToolBlocks:
|
||||
"""Tests for validate_mcp_tool_blocks."""
|
||||
|
||||
|
||||
@@ -342,6 +342,7 @@ class GraphExecution(GraphExecutionMeta):
|
||||
if (
|
||||
(block := get_block(exec.block_id))
|
||||
and block.block_type == BlockType.INPUT
|
||||
and "name" in exec.input_data
|
||||
)
|
||||
}
|
||||
),
|
||||
@@ -360,8 +361,10 @@ class GraphExecution(GraphExecutionMeta):
|
||||
outputs: CompletedBlockOutput = defaultdict(list)
|
||||
for exec in complete_node_executions:
|
||||
if (
|
||||
block := get_block(exec.block_id)
|
||||
) and block.block_type == BlockType.OUTPUT:
|
||||
(block := get_block(exec.block_id))
|
||||
and block.block_type == BlockType.OUTPUT
|
||||
and "name" in exec.input_data
|
||||
):
|
||||
outputs[exec.input_data["name"]].append(exec.input_data.get("value"))
|
||||
|
||||
return GraphExecution(
|
||||
|
||||
@@ -40,6 +40,9 @@ _MAX_PAGES = 100
|
||||
# LLM extraction timeout (seconds)
|
||||
_LLM_TIMEOUT = 30
|
||||
|
||||
SUGGESTION_THEMES = ["Learn", "Create", "Automate", "Organize"]
|
||||
PROMPTS_PER_THEME = 5
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""Mask an email for safe logging: 'alice@example.com' -> 'a***e@example.com'."""
|
||||
@@ -332,6 +335,11 @@ Fields:
|
||||
- current_software (list of strings): software/tools currently used
|
||||
- existing_automation (list of strings): existing automations
|
||||
- additional_notes (string): any additional context
|
||||
- suggested_prompts (object with keys "Learn", "Create", "Automate", "Organize"): for each key, \
|
||||
provide a list of 5 short action prompts (each under 20 words) that would help this person. \
|
||||
"Learn" = questions about AutoGPT features; "Create" = content/document generation tasks; \
|
||||
"Automate" = recurring workflow automation ideas; "Organize" = structuring/prioritizing tasks. \
|
||||
Should be specific to their industry, role, and pain points; actionable and conversational in tone.
|
||||
|
||||
Form data:
|
||||
"""
|
||||
@@ -378,6 +386,29 @@ async def extract_business_understanding(
|
||||
|
||||
# Filter out null values before constructing
|
||||
cleaned = {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
# Validate suggested_prompts: themed dict, filter >20 words, cap at 5 per theme
|
||||
raw_prompts = cleaned.get("suggested_prompts", {})
|
||||
if isinstance(raw_prompts, dict):
|
||||
themed: dict[str, list[str]] = {}
|
||||
for theme in SUGGESTION_THEMES:
|
||||
theme_prompts = raw_prompts.get(theme, [])
|
||||
if not isinstance(theme_prompts, list):
|
||||
continue
|
||||
valid = [
|
||||
s
|
||||
for p in theme_prompts
|
||||
if isinstance(p, str) and (s := p.strip()) and len(s.split()) <= 20
|
||||
]
|
||||
if valid:
|
||||
themed[theme] = valid[:PROMPTS_PER_THEME]
|
||||
if themed:
|
||||
cleaned["suggested_prompts"] = themed
|
||||
else:
|
||||
cleaned.pop("suggested_prompts", None)
|
||||
else:
|
||||
cleaned.pop("suggested_prompts", None)
|
||||
|
||||
return BusinessUnderstandingInput(**cleaned)
|
||||
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ async def test_populate_understanding_full_flow():
|
||||
],
|
||||
}
|
||||
mock_input = MagicMock()
|
||||
mock_input.suggested_prompts = {"Learn": ["P1"], "Create": ["P2"]}
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -397,15 +398,25 @@ def test_extraction_prompt_no_format_placeholders():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_business_understanding_success():
|
||||
"""Happy path: LLM returns valid JSON that maps to BusinessUnderstandingInput."""
|
||||
async def test_extract_business_understanding_themed_prompts():
|
||||
"""Happy path: LLM returns themed prompts as dict."""
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = json.dumps(
|
||||
{
|
||||
"user_name": "Alice",
|
||||
"business_name": "Acme Corp",
|
||||
"industry": "Technology",
|
||||
"pain_points": ["manual reporting"],
|
||||
"suggested_prompts": {
|
||||
"Learn": ["Learn 1", "Learn 2", "Learn 3", "Learn 4", "Learn 5"],
|
||||
"Create": [
|
||||
"Create 1",
|
||||
"Create 2",
|
||||
"Create 3",
|
||||
"Create 4",
|
||||
"Create 5",
|
||||
],
|
||||
"Automate": ["Auto 1", "Auto 2", "Auto 3", "Auto 4", "Auto 5"],
|
||||
"Organize": ["Org 1", "Org 2", "Org 3", "Org 4", "Org 5"],
|
||||
},
|
||||
}
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
@@ -418,9 +429,42 @@ async def test_extract_business_understanding_success():
|
||||
result = await extract_business_understanding("Q: Name?\nA: Alice")
|
||||
|
||||
assert result.user_name == "Alice"
|
||||
assert result.business_name == "Acme Corp"
|
||||
assert result.industry == "Technology"
|
||||
assert result.pain_points == ["manual reporting"]
|
||||
assert result.suggested_prompts is not None
|
||||
assert len(result.suggested_prompts) == 4
|
||||
assert len(result.suggested_prompts["Learn"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_themed_prompts_filters_long_and_unknown_keys():
|
||||
"""Long prompts are filtered, unknown keys are dropped, each theme capped at 5."""
|
||||
long_prompt = " ".join(["word"] * 21)
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = json.dumps(
|
||||
{
|
||||
"user_name": "Alice",
|
||||
"suggested_prompts": {
|
||||
"Learn": [long_prompt, "Valid learn 1", "Valid learn 2"],
|
||||
"UnknownTheme": ["Should be dropped"],
|
||||
"Automate": ["A1", "A2", "A3", "A4", "A5", "A6"],
|
||||
},
|
||||
}
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
|
||||
with patch("backend.data.tally.AsyncOpenAI", return_value=mock_client):
|
||||
result = await extract_business_understanding("Q: Name?\nA: Alice")
|
||||
|
||||
assert result.suggested_prompts is not None
|
||||
# Unknown key dropped
|
||||
assert "UnknownTheme" not in result.suggested_prompts
|
||||
# Long prompt filtered
|
||||
assert result.suggested_prompts["Learn"] == ["Valid learn 1", "Valid learn 2"]
|
||||
# Capped at 5
|
||||
assert result.suggested_prompts["Automate"] == ["A1", "A2", "A3", "A4", "A5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -49,6 +49,25 @@ def _json_to_list(value: Any) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _json_to_themed_prompts(value: Any) -> dict[str, list[str]]:
|
||||
"""Convert Json field to themed prompts dict.
|
||||
|
||||
Handles both the new ``dict[str, list[str]]`` format and the legacy
|
||||
``list[str]`` format. Legacy rows are placed under a ``"General"`` key so
|
||||
existing personalised prompts remain readable until a backfill regenerates
|
||||
them into the proper themed shape.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: [i for i in v if isinstance(i, str)]
|
||||
for k, v in value.items()
|
||||
if isinstance(k, str) and isinstance(v, list)
|
||||
}
|
||||
if isinstance(value, list) and value:
|
||||
return {"General": [str(p) for p in value if isinstance(p, str)]}
|
||||
return {}
|
||||
|
||||
|
||||
class BusinessUnderstandingInput(pydantic.BaseModel):
|
||||
"""Input model for updating business understanding - all fields optional for incremental updates."""
|
||||
|
||||
@@ -104,6 +123,11 @@ class BusinessUnderstandingInput(pydantic.BaseModel):
|
||||
None, description="Any additional context"
|
||||
)
|
||||
|
||||
# Suggested prompts (UI-only, not included in system prompt)
|
||||
suggested_prompts: Optional[dict[str, list[str]]] = pydantic.Field(
|
||||
None, description="LLM-generated suggested prompts grouped by theme"
|
||||
)
|
||||
|
||||
|
||||
class BusinessUnderstanding(pydantic.BaseModel):
|
||||
"""Full business understanding model returned from database."""
|
||||
@@ -140,6 +164,9 @@ class BusinessUnderstanding(pydantic.BaseModel):
|
||||
# Additional context
|
||||
additional_notes: Optional[str] = None
|
||||
|
||||
# Suggested prompts (UI-only, not included in system prompt)
|
||||
suggested_prompts: dict[str, list[str]] = pydantic.Field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_record: CoPilotUnderstanding) -> "BusinessUnderstanding":
|
||||
"""Convert database record to Pydantic model."""
|
||||
@@ -167,6 +194,7 @@ class BusinessUnderstanding(pydantic.BaseModel):
|
||||
current_software=_json_to_list(business.get("current_software")),
|
||||
existing_automation=_json_to_list(business.get("existing_automation")),
|
||||
additional_notes=business.get("additional_notes"),
|
||||
suggested_prompts=_json_to_themed_prompts(data.get("suggested_prompts")),
|
||||
)
|
||||
|
||||
|
||||
@@ -246,33 +274,22 @@ async def get_business_understanding(
|
||||
return understanding
|
||||
|
||||
|
||||
async def upsert_business_understanding(
|
||||
user_id: str,
|
||||
def merge_business_understanding_data(
|
||||
existing_data: dict[str, Any],
|
||||
input_data: BusinessUnderstandingInput,
|
||||
) -> BusinessUnderstanding:
|
||||
"""
|
||||
Create or update business understanding with incremental merge strategy.
|
||||
) -> dict[str, Any]:
|
||||
"""Merge new input into existing data dict using incremental strategy.
|
||||
|
||||
- String fields: new value overwrites if provided (not None)
|
||||
- List fields: new items are appended to existing (deduplicated)
|
||||
- suggested_prompts: fully replaced if provided (not None)
|
||||
|
||||
Data is stored as: {name: ..., business: {version: 1, ...}}
|
||||
Returns the merged data dict (mutates and returns *existing_data*).
|
||||
"""
|
||||
# Get existing record for merge
|
||||
existing = await CoPilotUnderstanding.prisma().find_unique(
|
||||
where={"userId": user_id}
|
||||
)
|
||||
|
||||
# Get existing data structure or start fresh
|
||||
existing_data: dict[str, Any] = {}
|
||||
if existing and isinstance(existing.data, dict):
|
||||
existing_data = dict(existing.data)
|
||||
|
||||
existing_business: dict[str, Any] = {}
|
||||
if isinstance(existing_data.get("business"), dict):
|
||||
existing_business = dict(existing_data["business"])
|
||||
|
||||
# Business fields (stored inside business object)
|
||||
business_string_fields = [
|
||||
"job_title",
|
||||
"business_name",
|
||||
@@ -310,16 +327,48 @@ async def upsert_business_understanding(
|
||||
merged = _merge_lists(existing_list, value)
|
||||
existing_business[field] = merged
|
||||
|
||||
# Suggested prompts - fully replace if provided
|
||||
if input_data.suggested_prompts is not None:
|
||||
existing_data["suggested_prompts"] = input_data.suggested_prompts
|
||||
|
||||
# Set version and nest business data
|
||||
existing_business["version"] = 1
|
||||
existing_data["business"] = existing_business
|
||||
|
||||
return existing_data
|
||||
|
||||
|
||||
async def upsert_business_understanding(
|
||||
user_id: str,
|
||||
input_data: BusinessUnderstandingInput,
|
||||
) -> BusinessUnderstanding:
|
||||
"""
|
||||
Create or update business understanding with incremental merge strategy.
|
||||
|
||||
- String fields: new value overwrites if provided (not None)
|
||||
- List fields: new items are appended to existing (deduplicated)
|
||||
- suggested_prompts: fully replaced if provided (not None)
|
||||
|
||||
Data is stored as: {name: ..., business: {version: 1, ...}}
|
||||
"""
|
||||
# Get existing record for merge
|
||||
existing = await CoPilotUnderstanding.prisma().find_unique(
|
||||
where={"userId": user_id}
|
||||
)
|
||||
|
||||
# Get existing data structure or start fresh
|
||||
existing_data: dict[str, Any] = {}
|
||||
if existing and isinstance(existing.data, dict):
|
||||
existing_data = dict(existing.data)
|
||||
|
||||
merged_data = merge_business_understanding_data(existing_data, input_data)
|
||||
|
||||
# Upsert with the merged data
|
||||
record = await CoPilotUnderstanding.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
data={
|
||||
"create": {"userId": user_id, "data": SafeJson(existing_data)},
|
||||
"update": {"data": SafeJson(existing_data)},
|
||||
"create": {"userId": user_id, "data": SafeJson(merged_data)},
|
||||
"update": {"data": SafeJson(merged_data)},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
148
autogpt_platform/backend/backend/data/understanding_test.py
Normal file
148
autogpt_platform/backend/backend/data/understanding_test.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests for business understanding merge and format logic."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.data.understanding import (
|
||||
BusinessUnderstanding,
|
||||
BusinessUnderstandingInput,
|
||||
_json_to_themed_prompts,
|
||||
format_understanding_for_prompt,
|
||||
merge_business_understanding_data,
|
||||
)
|
||||
|
||||
|
||||
def _make_input(**kwargs: Any) -> BusinessUnderstandingInput:
|
||||
"""Create a BusinessUnderstandingInput with only the specified fields."""
|
||||
return BusinessUnderstandingInput.model_validate(kwargs)
|
||||
|
||||
|
||||
# ─── merge_business_understanding_data: themed prompts ─────────────────
|
||||
|
||||
|
||||
def test_merge_themed_prompts_overwrites_existing():
|
||||
"""New themed prompts should fully replace existing ones (not merge)."""
|
||||
existing = {
|
||||
"name": "Alice",
|
||||
"business": {"industry": "Tech", "version": 1},
|
||||
"suggested_prompts": {
|
||||
"Learn": ["Old learn prompt"],
|
||||
"Create": ["Old create prompt"],
|
||||
},
|
||||
}
|
||||
new_prompts = {
|
||||
"Automate": ["Schedule daily reports", "Set up email alerts"],
|
||||
"Organize": ["Sort inbox by priority"],
|
||||
}
|
||||
input_data = _make_input(suggested_prompts=new_prompts)
|
||||
|
||||
result = merge_business_understanding_data(existing, input_data)
|
||||
|
||||
assert result["suggested_prompts"] == new_prompts
|
||||
|
||||
|
||||
def test_merge_themed_prompts_none_preserves_existing():
|
||||
"""When input has suggested_prompts=None, existing themed prompts are preserved."""
|
||||
existing_prompts = {
|
||||
"Learn": ["How to automate?"],
|
||||
"Create": ["Build a chatbot"],
|
||||
}
|
||||
existing = {
|
||||
"name": "Alice",
|
||||
"business": {"industry": "Tech", "version": 1},
|
||||
"suggested_prompts": existing_prompts,
|
||||
}
|
||||
input_data = _make_input(industry="Finance")
|
||||
|
||||
result = merge_business_understanding_data(existing, input_data)
|
||||
|
||||
assert result["suggested_prompts"] == existing_prompts
|
||||
assert result["business"]["industry"] == "Finance"
|
||||
|
||||
|
||||
# ─── from_db: themed prompts deserialization ───────────────────────────
|
||||
|
||||
|
||||
def test_from_db_themed_prompts():
|
||||
"""from_db correctly deserializes a themed dict for suggested_prompts."""
|
||||
themed = {
|
||||
"Learn": ["What can I automate?"],
|
||||
"Create": ["Build a workflow"],
|
||||
}
|
||||
db_record = MagicMock()
|
||||
db_record.id = "test-id"
|
||||
db_record.userId = "user-1"
|
||||
db_record.createdAt = datetime.now(tz=timezone.utc)
|
||||
db_record.updatedAt = datetime.now(tz=timezone.utc)
|
||||
db_record.data = {
|
||||
"name": "Alice",
|
||||
"business": {"industry": "Tech", "version": 1},
|
||||
"suggested_prompts": themed,
|
||||
}
|
||||
|
||||
result = BusinessUnderstanding.from_db(db_record)
|
||||
|
||||
assert result.suggested_prompts == themed
|
||||
|
||||
|
||||
def test_from_db_legacy_list_prompts_preserved_under_general():
|
||||
"""from_db preserves legacy list[str] prompts under a 'General' key."""
|
||||
db_record = MagicMock()
|
||||
db_record.id = "test-id"
|
||||
db_record.userId = "user-1"
|
||||
db_record.createdAt = datetime.now(tz=timezone.utc)
|
||||
db_record.updatedAt = datetime.now(tz=timezone.utc)
|
||||
db_record.data = {
|
||||
"name": "Alice",
|
||||
"business": {"industry": "Tech", "version": 1},
|
||||
"suggested_prompts": ["Old prompt 1", "Old prompt 2"],
|
||||
}
|
||||
|
||||
result = BusinessUnderstanding.from_db(db_record)
|
||||
|
||||
assert result.suggested_prompts == {"General": ["Old prompt 1", "Old prompt 2"]}
|
||||
|
||||
|
||||
# ─── _json_to_themed_prompts helper ───────────────────────────────────
|
||||
|
||||
|
||||
def test_json_to_themed_prompts_with_dict():
|
||||
value = {"Learn": ["a", "b"], "Create": ["c"]}
|
||||
assert _json_to_themed_prompts(value) == {"Learn": ["a", "b"], "Create": ["c"]}
|
||||
|
||||
|
||||
def test_json_to_themed_prompts_with_list_returns_general():
|
||||
assert _json_to_themed_prompts(["a", "b"]) == {"General": ["a", "b"]}
|
||||
|
||||
|
||||
def test_json_to_themed_prompts_with_none_returns_empty():
|
||||
assert _json_to_themed_prompts(None) == {}
|
||||
|
||||
|
||||
# ─── format_understanding_for_prompt: excludes themed prompts ──────────
|
||||
|
||||
|
||||
def test_format_understanding_excludes_themed_prompts():
|
||||
"""Themed suggested_prompts are UI-only and must NOT appear in the system prompt."""
|
||||
understanding = BusinessUnderstanding(
|
||||
id="test-id",
|
||||
user_id="user-1",
|
||||
created_at=datetime.now(tz=timezone.utc),
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
user_name="Alice",
|
||||
industry="Technology",
|
||||
suggested_prompts={
|
||||
"Learn": ["Automate reports"],
|
||||
"Create": ["Set up alerts", "Track KPIs"],
|
||||
},
|
||||
)
|
||||
|
||||
formatted = format_understanding_for_prompt(understanding)
|
||||
|
||||
assert "Alice" in formatted
|
||||
assert "Technology" in formatted
|
||||
assert "suggested_prompts" not in formatted
|
||||
assert "Automate reports" not in formatted
|
||||
assert "Set up alerts" not in formatted
|
||||
assert "Track KPIs" not in formatted
|
||||
@@ -39,6 +39,8 @@ class Flag(str, Enum):
|
||||
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment"
|
||||
CHAT = "chat"
|
||||
COPILOT_SDK = "copilot-sdk"
|
||||
COPILOT_DAILY_TOKEN_LIMIT = "copilot-daily-token-limit"
|
||||
COPILOT_WEEKLY_TOKEN_LIMIT = "copilot-weekly-token-limit"
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
|
||||
7
autogpt_platform/backend/snapshots/get_rate_limit
Normal file
7
autogpt_platform/backend/snapshots/get_rate_limit
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 500000,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 3000000
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 0,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 0,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 3000000
|
||||
}
|
||||
@@ -526,7 +526,12 @@ class TestValidateOrchestratorBlocks:
|
||||
"id": AGENT_INPUT_BLOCK_ID,
|
||||
"name": "AgentInputBlock",
|
||||
"inputSchema": {
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"outputSchema": {"properties": {"result": {}}},
|
||||
@@ -537,6 +542,7 @@ class TestValidateOrchestratorBlocks:
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
},
|
||||
"required": ["name"],
|
||||
@@ -683,7 +689,12 @@ class TestOrchestratorE2EPipeline:
|
||||
"id": AGENT_INPUT_BLOCK_ID,
|
||||
"name": "AgentInputBlock",
|
||||
"inputSchema": {
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"outputSchema": {"properties": {"result": {}}},
|
||||
@@ -694,6 +705,7 @@ class TestOrchestratorE2EPipeline:
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"value": {},
|
||||
},
|
||||
"required": ["name"],
|
||||
|
||||
@@ -254,7 +254,6 @@ class TestDataCreator:
|
||||
"value": "",
|
||||
"advanced": False,
|
||||
"description": None,
|
||||
"placeholder_values": [],
|
||||
},
|
||||
metadata={"position": {"x": -1012, "y": 674}},
|
||||
)
|
||||
@@ -274,7 +273,6 @@ class TestDataCreator:
|
||||
"value": "",
|
||||
"advanced": False,
|
||||
"description": None,
|
||||
"placeholder_values": [],
|
||||
},
|
||||
metadata={"position": {"x": -1117, "y": 78}},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ const config: StorybookConfig = {
|
||||
"../src/components/atoms/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/ai-elements/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/renderers/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
"@storybook/addon-a11y",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
|
||||
import { Gauge } from "@phosphor-icons/react/dist/ssr";
|
||||
|
||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
@@ -21,6 +22,11 @@ const sidebarLinkGroups = [
|
||||
href: "/admin/impersonation",
|
||||
icon: <UserSearch className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Rate Limits",
|
||||
href: "/admin/rate-limits",
|
||||
icon: <Gauge className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Execution Analytics",
|
||||
href: "/admin/execution-analytics",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
function UsageBar({ used, limit }: { used: number; limit: number }) {
|
||||
if (limit === 0) {
|
||||
return <span className="text-sm text-gray-500">Unlimited</span>;
|
||||
}
|
||||
const pct = Math.min((used / limit) * 100, 100);
|
||||
const color =
|
||||
pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-green-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{formatTokens(used)} used</span>
|
||||
<span>{formatTokens(limit)} limit</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
{pct.toFixed(1)}% used
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: UserRateLimitResponse;
|
||||
onReset: (resetWeekly: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RateLimitDisplay({ data, onReset }: Props) {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetWeekly, setResetWeekly] = useState(false);
|
||||
|
||||
async function handleReset() {
|
||||
const msg = resetWeekly
|
||||
? "Reset both daily and weekly usage counters to zero?"
|
||||
: "Reset daily usage counter to zero?";
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await onReset(resetWeekly);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const nothingToReset = resetWeekly
|
||||
? data.daily_tokens_used === 0 && data.weekly_tokens_used === 0
|
||||
: data.daily_tokens_used === 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-white p-6 dark:bg-gray-900">
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
Rate Limits for {data.user_id}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Daily Usage
|
||||
</h3>
|
||||
<UsageBar
|
||||
used={data.daily_tokens_used}
|
||||
limit={data.daily_token_limit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Weekly Usage
|
||||
</h3>
|
||||
<UsageBar
|
||||
used={data.weekly_tokens_used}
|
||||
limit={data.weekly_token_limit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3 border-t pt-4">
|
||||
<select
|
||||
value={resetWeekly ? "both" : "daily"}
|
||||
onChange={(e) => setResetWeekly(e.target.value === "both")}
|
||||
className="rounded-md border bg-white px-3 py-1.5 text-sm dark:bg-gray-800 dark:text-gray-200"
|
||||
disabled={isResetting}
|
||||
>
|
||||
<option value="daily">Reset daily only</option>
|
||||
<option value="both">Reset daily + weekly</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting || nothingToReset}
|
||||
>
|
||||
{isResetting ? "Resetting..." : "Reset Usage"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
|
||||
import {
|
||||
getV2GetUserRateLimit,
|
||||
postV2ResetUserRateLimitUsage,
|
||||
} from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
import { RateLimitDisplay } from "./RateLimitDisplay";
|
||||
|
||||
export function RateLimitManager() {
|
||||
const { toast } = useToast();
|
||||
const [userIdInput, setUserIdInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rateLimitData, setRateLimitData] =
|
||||
useState<UserRateLimitResponse | null>(null);
|
||||
|
||||
async function handleLookup() {
|
||||
const trimmed = userIdInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getV2GetUserRateLimit({ user_id: trimmed });
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to fetch rate limit");
|
||||
}
|
||||
setRateLimitData(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching rate limit:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch user rate limit. Check the user ID.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setRateLimitData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(resetWeekly: boolean) {
|
||||
if (!rateLimitData) return;
|
||||
|
||||
try {
|
||||
const response = await postV2ResetUserRateLimitUsage({
|
||||
user_id: rateLimitData.user_id,
|
||||
reset_weekly: resetWeekly,
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to reset usage");
|
||||
}
|
||||
setRateLimitData(response.data);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: resetWeekly
|
||||
? "Daily and weekly usage reset to zero."
|
||||
: "Daily usage reset to zero.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error resetting rate limit:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to reset rate limit usage.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border bg-white p-6 dark:bg-gray-900">
|
||||
<Label htmlFor="userId" className="mb-2 block text-sm font-medium">
|
||||
User ID
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="userId"
|
||||
placeholder="Enter user ID to look up rate limits..."
|
||||
value={userIdInput}
|
||||
onChange={(e) => setUserIdInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLookup()}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLookup}
|
||||
disabled={isLoading || !userIdInput.trim()}
|
||||
>
|
||||
{isLoading ? "Loading..." : <MagnifyingGlass size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rateLimitData && (
|
||||
<RateLimitDisplay data={rateLimitData} onReset={handleReset} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
||||
import { RateLimitManager } from "./components/RateLimitManager";
|
||||
|
||||
function RateLimitsDashboard() {
|
||||
return (
|
||||
<div className="mx-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Rate Limits</h1>
|
||||
<p className="text-gray-500">
|
||||
Check and manage CoPilot rate limits per user
|
||||
</p>
|
||||
</div>
|
||||
<RateLimitManager />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function RateLimitsDashboardPage() {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedDashboard = await withAdminAccess(RateLimitsDashboard);
|
||||
return <ProtectedDashboard />;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
|
||||
import { useGetV2GetCopilotUsage } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UploadSimple } from "@phosphor-icons/react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
|
||||
import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog";
|
||||
@@ -11,6 +16,7 @@ import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
|
||||
import { NotificationBanner } from "./components/NotificationBanner/NotificationBanner";
|
||||
import { NotificationDialog } from "./components/NotificationDialog/NotificationDialog";
|
||||
import { RateLimitResetDialog } from "./components/RateLimitResetDialog/RateLimitResetDialog";
|
||||
import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader";
|
||||
import { useCopilotPage } from "./useCopilotPage";
|
||||
|
||||
@@ -65,6 +71,7 @@ export function CopilotPage() {
|
||||
error,
|
||||
stop,
|
||||
isReconnecting,
|
||||
isSyncing,
|
||||
createSession,
|
||||
onSend,
|
||||
isLoadingSession,
|
||||
@@ -88,8 +95,45 @@ export function CopilotPage() {
|
||||
isDeleting,
|
||||
handleConfirmDelete,
|
||||
handleCancelDelete,
|
||||
// Rate limit reset
|
||||
rateLimitMessage,
|
||||
dismissRateLimit,
|
||||
} = useCopilotPage();
|
||||
|
||||
const {
|
||||
data: usage,
|
||||
isSuccess: hasUsage,
|
||||
isError: usageError,
|
||||
} = useGetV2GetCopilotUsage({
|
||||
query: {
|
||||
select: (res) => res.data as CoPilotUsageStatus,
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
},
|
||||
});
|
||||
const resetCost = usage?.reset_cost;
|
||||
|
||||
const isBillingEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT);
|
||||
const { credits, fetchCredits } = useCredits({ fetchInitialCredits: true });
|
||||
const hasInsufficientCredits =
|
||||
credits !== null && resetCost != null && credits < resetCost;
|
||||
|
||||
// Fall back to a toast when the credit-based reset feature is disabled or
|
||||
// when the usage query fails (so the user still gets feedback).
|
||||
useEffect(() => {
|
||||
if (
|
||||
rateLimitMessage &&
|
||||
(usageError || (hasUsage && (resetCost ?? 0) <= 0))
|
||||
) {
|
||||
toast({
|
||||
title: "Usage limit reached",
|
||||
description: rateLimitMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
dismissRateLimit();
|
||||
}
|
||||
}, [rateLimitMessage, resetCost, hasUsage, usageError, dismissRateLimit]);
|
||||
|
||||
if (isUserLoading || !isLoggedIn) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#f8f8f9]">
|
||||
@@ -135,6 +179,7 @@ export function CopilotPage() {
|
||||
isSessionError={isSessionError}
|
||||
isCreatingSession={isCreatingSession}
|
||||
isReconnecting={isReconnecting}
|
||||
isSyncing={isSyncing}
|
||||
onCreateSession={createSession}
|
||||
onSend={onSend}
|
||||
onStop={stop}
|
||||
@@ -166,6 +211,20 @@ export function CopilotPage() {
|
||||
/>
|
||||
)}
|
||||
<NotificationDialog />
|
||||
<RateLimitResetDialog
|
||||
isOpen={!!rateLimitMessage && hasUsage && (resetCost ?? 0) > 0}
|
||||
onClose={dismissRateLimit}
|
||||
resetCost={resetCost ?? 0}
|
||||
resetMessage={rateLimitMessage ?? ""}
|
||||
isWeeklyExhausted={
|
||||
hasUsage &&
|
||||
usage.weekly.limit > 0 &&
|
||||
usage.weekly.used >= usage.weekly.limit
|
||||
}
|
||||
hasInsufficientCredits={hasInsufficientCredits}
|
||||
isBillingEnabled={isBillingEnabled}
|
||||
onCreditChange={fetchCredits}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ChatContainerProps {
|
||||
isCreatingSession: boolean;
|
||||
/** True when backend has an active stream but we haven't reconnected yet. */
|
||||
isReconnecting?: boolean;
|
||||
/** True while re-syncing session state after device wake. */
|
||||
isSyncing?: boolean;
|
||||
onCreateSession: () => void | Promise<string>;
|
||||
onSend: (message: string, files?: File[]) => void | Promise<void>;
|
||||
onStop: () => void;
|
||||
@@ -35,6 +37,7 @@ export const ChatContainer = ({
|
||||
isSessionError,
|
||||
isCreatingSession,
|
||||
isReconnecting,
|
||||
isSyncing,
|
||||
onCreateSession,
|
||||
onSend,
|
||||
onStop,
|
||||
@@ -46,6 +49,7 @@ export const ChatContainer = ({
|
||||
status === "streaming" ||
|
||||
status === "submitted" ||
|
||||
!!isReconnecting ||
|
||||
!!isSyncing ||
|
||||
isLoadingSession ||
|
||||
!!isSessionError;
|
||||
const inputLayoutId = "copilot-2-chat-input";
|
||||
|
||||
@@ -93,6 +93,12 @@ export function ChatInput({
|
||||
baseHandleChange(e);
|
||||
}
|
||||
|
||||
const resolvedPlaceholder = isRecording
|
||||
? ""
|
||||
: isTranscribing
|
||||
? "Transcribing..."
|
||||
: placeholder;
|
||||
|
||||
const canSend =
|
||||
!disabled &&
|
||||
(!!value.trim() || hasFiles) &&
|
||||
@@ -129,7 +135,7 @@ export function ChatInput({
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isInputDisabled}
|
||||
placeholder={isTranscribing ? "Transcribing..." : placeholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
/>
|
||||
{isRecording && !value && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import {
|
||||
Message,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
splitReasoningAndResponse,
|
||||
} from "./helpers";
|
||||
import { AssistantMessageActions } from "./components/AssistantMessageActions";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { CollapsedToolGroup } from "./components/CollapsedToolGroup";
|
||||
import { MessageAttachments } from "./components/MessageAttachments";
|
||||
import { MessagePartRenderer } from "./components/MessagePartRenderer";
|
||||
@@ -240,6 +245,11 @@ export function ChatMessagesContainer({
|
||||
<ThinkingIndicator active={showThinking} />
|
||||
)}
|
||||
</MessageContent>
|
||||
{message.role === "user" && textParts.length > 0 && (
|
||||
<MessageActions className="mt-1 justify-end opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
||||
<CopyButton text={textParts.map((p) => p.text).join("\n")} />
|
||||
</MessageActions>
|
||||
)}
|
||||
{fileParts.length > 0 && (
|
||||
<MessageAttachments
|
||||
files={fileParts}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { MessageAction } from "@/components/ai-elements/message";
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import { Check, Copy } from "@phosphor-icons/react";
|
||||
import { Check, CopySimple } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
@@ -31,10 +31,12 @@ export function CopyButton({ text }: Props) {
|
||||
|
||||
return (
|
||||
<MessageAction
|
||||
tooltip={copied ? "Copied!" : "Copy to clipboard"}
|
||||
tooltip={copied ? "Copied!" : "Copy"}
|
||||
onClick={handleCopy}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? <Check size={16} /> : <CopySimple size={16} weight="regular" />}
|
||||
</MessageAction>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle,
|
||||
CircleNotch,
|
||||
DotsThree,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
@@ -36,7 +37,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useCopilotUIStore } from "../../store";
|
||||
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
|
||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||
import { PulseLoader } from "../PulseLoader/PulseLoader";
|
||||
import { UsageLimits } from "../UsageLimits/UsageLimits";
|
||||
|
||||
export function ChatSidebar() {
|
||||
@@ -367,7 +367,10 @@ export function ChatSidebar() {
|
||||
{session.is_processing &&
|
||||
session.id !== sessionId &&
|
||||
!completedSessionIDs.has(session.id) && (
|
||||
<PulseLoader size={16} className="shrink-0" />
|
||||
<CircleNotch
|
||||
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{completedSessionIDs.has(session.id) &&
|
||||
session.id !== sessionId && (
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useGetV2GetSuggestedPrompts } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getGreetingName,
|
||||
getInputPlaceholder,
|
||||
getQuickActions,
|
||||
getSuggestionThemes,
|
||||
} from "./helpers";
|
||||
import { SuggestionThemes } from "./components/SuggestionThemes/SuggestionThemes";
|
||||
|
||||
interface Props {
|
||||
inputLayoutId: string;
|
||||
@@ -33,25 +34,35 @@ export function EmptySession({
|
||||
}: Props) {
|
||||
const { user } = useSupabase();
|
||||
const greetingName = getGreetingName(user);
|
||||
const quickActions = getQuickActions();
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
|
||||
const { data: suggestedPromptsResponse, isLoading: isLoadingPrompts } =
|
||||
useGetV2GetSuggestedPrompts({
|
||||
query: { staleTime: Infinity, gcTime: Infinity, refetchOnMount: false },
|
||||
});
|
||||
const themes = getSuggestionThemes(
|
||||
suggestedPromptsResponse?.status === 200
|
||||
? suggestedPromptsResponse.data.themes
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const [inputPlaceholder, setInputPlaceholder] = useState(
|
||||
getInputPlaceholder(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
||||
}, [window.innerWidth]);
|
||||
|
||||
async function handleQuickActionClick(action: string) {
|
||||
if (isCreatingSession || loadingAction !== null) return;
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await onSend(action);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
function handleResize() {
|
||||
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
||||
}
|
||||
}
|
||||
handleResize();
|
||||
const mql = window.matchMedia("(max-width: 500px)");
|
||||
mql.addEventListener("change", handleResize);
|
||||
const mql2 = window.matchMedia("(max-width: 1080px)");
|
||||
mql2.addEventListener("change", handleResize);
|
||||
return () => {
|
||||
mql.removeEventListener("change", handleResize);
|
||||
mql2.removeEventListener("change", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
|
||||
@@ -89,30 +100,19 @@ export function EmptySession({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => void handleQuickActionClick(action)}
|
||||
disabled={isCreatingSession || loadingAction !== null}
|
||||
aria-busy={loadingAction === action}
|
||||
leftIcon={
|
||||
loadingAction === action ? (
|
||||
<SpinnerGapIcon
|
||||
className="h-4 w-4 animate-spin"
|
||||
weight="bold"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isLoadingPrompts ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-28 shrink-0 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SuggestionThemes
|
||||
themes={themes}
|
||||
onSend={onSend}
|
||||
disabled={isCreatingSession}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/molecules/Popover/Popover";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
PaintBrushIcon,
|
||||
LightningIcon,
|
||||
ListChecksIcon,
|
||||
SpinnerGapIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import type { SuggestionTheme } from "../../helpers";
|
||||
|
||||
const THEME_ICONS: Record<string, typeof BookOpenIcon> = {
|
||||
Learn: BookOpenIcon,
|
||||
Create: PaintBrushIcon,
|
||||
Automate: LightningIcon,
|
||||
Organize: ListChecksIcon,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
themes: SuggestionTheme[];
|
||||
onSend: (prompt: string) => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SuggestionThemes({ themes, onSend, disabled }: Props) {
|
||||
const [openTheme, setOpenTheme] = useState<string | null>(null);
|
||||
const [loadingPrompt, setLoadingPrompt] = useState<string | null>(null);
|
||||
|
||||
async function handlePromptClick(theme: string, prompt: string) {
|
||||
if (disabled || loadingPrompt) return;
|
||||
setLoadingPrompt(`${theme}:${prompt}`);
|
||||
try {
|
||||
await onSend(prompt);
|
||||
} finally {
|
||||
setLoadingPrompt(null);
|
||||
setOpenTheme(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{themes.map((theme) => {
|
||||
const Icon = THEME_ICONS[theme.name];
|
||||
return (
|
||||
<Popover
|
||||
key={theme.name}
|
||||
open={openTheme === theme.name}
|
||||
onOpenChange={(open) => setOpenTheme(open ? theme.name : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
disabled={disabled || loadingPrompt !== null}
|
||||
className="shrink-0 gap-2 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
||||
>
|
||||
{Icon && <Icon size={16} weight="regular" />}
|
||||
{theme.name}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-80 p-2">
|
||||
<ul className="grid gap-0.5">
|
||||
{theme.prompts.map((prompt) => (
|
||||
<li key={prompt}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loadingPrompt !== null}
|
||||
onClick={() => void handlePromptClick(theme.name, prompt)}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{loadingPrompt === `${theme.name}:${prompt}` ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<SpinnerGapIcon
|
||||
className="h-4 w-4 animate-spin"
|
||||
weight="bold"
|
||||
/>
|
||||
{prompt}
|
||||
</span>
|
||||
) : (
|
||||
prompt
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,87 @@ export function getInputPlaceholder(width?: number) {
|
||||
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
||||
}
|
||||
|
||||
export function getQuickActions() {
|
||||
return [
|
||||
"I don't know where to start, just ask me stuff",
|
||||
"I do the same thing every week and it's killing me",
|
||||
"Help me find where I'm wasting my time",
|
||||
];
|
||||
export interface SuggestionTheme {
|
||||
name: string;
|
||||
prompts: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES: SuggestionTheme[] = [
|
||||
{
|
||||
name: "Learn",
|
||||
prompts: [
|
||||
"What can AutoGPT do for me?",
|
||||
"Show me how agents work",
|
||||
"What integrations are available?",
|
||||
"How do I schedule an agent?",
|
||||
"What are the most popular agents?",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Create",
|
||||
prompts: [
|
||||
"Draft a weekly status report",
|
||||
"Generate social media posts for my business",
|
||||
"Create a competitive analysis summary",
|
||||
"Write onboarding emails for new hires",
|
||||
"Build a content calendar for next month",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Automate",
|
||||
prompts: [
|
||||
"Monitor relevant websites for changes",
|
||||
"Send me a daily news digest on my industry",
|
||||
"Auto-reply to common customer questions",
|
||||
"Track price changes on products I sell",
|
||||
"Summarize my emails every morning",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Organize",
|
||||
prompts: [
|
||||
"Summarize my unread emails",
|
||||
"Create a project timeline from my notes",
|
||||
"Prioritize my task list by urgency",
|
||||
"Build a decision matrix for vendor selection",
|
||||
"Organize my meeting notes into action items",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getSuggestionThemes(
|
||||
apiThemes?: SuggestionTheme[],
|
||||
): SuggestionTheme[] {
|
||||
if (!apiThemes?.length) {
|
||||
return DEFAULT_THEMES;
|
||||
}
|
||||
|
||||
const promptsByTheme = new Map(
|
||||
apiThemes.map((theme) => [theme.name, theme.prompts] as const),
|
||||
);
|
||||
|
||||
// Legacy users have prompts under "General" — distribute them across themes
|
||||
const generalPrompts = (promptsByTheme.get("General") ?? []).filter(
|
||||
(p) => p.trim().length > 0,
|
||||
);
|
||||
|
||||
return DEFAULT_THEMES.map((theme, idx) => {
|
||||
const personalized = (promptsByTheme.get(theme.name) ?? []).filter(
|
||||
(p) => p.trim().length > 0,
|
||||
);
|
||||
|
||||
// Spread legacy "General" prompts round-robin across themes
|
||||
const legacySlice = generalPrompts.filter(
|
||||
(_, i) => i % DEFAULT_THEMES.length === idx,
|
||||
);
|
||||
|
||||
return {
|
||||
name: theme.name,
|
||||
prompts: Array.from(
|
||||
new Set([...personalized, ...legacySlice, ...theme.prompts]),
|
||||
).slice(0, theme.prompts.length),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getGreetingName(user?: User | null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle,
|
||||
CircleNotch,
|
||||
PlusIcon,
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { Drawer } from "vaul";
|
||||
import { useCopilotUIStore } from "../../store";
|
||||
import { PulseLoader } from "../PulseLoader/PulseLoader";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -165,7 +165,10 @@ export function MobileDrawer({
|
||||
{session.is_processing &&
|
||||
!completedSessionIDs.has(session.id) &&
|
||||
session.id !== currentSessionId && (
|
||||
<PulseLoader size={8} className="shrink-0" />
|
||||
<CircleNotch
|
||||
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{completedSessionIDs.has(session.id) &&
|
||||
session.id !== currentSessionId && (
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
.loader {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loader::before,
|
||||
.loader::after {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
animation: ripple 2s linear infinite;
|
||||
}
|
||||
|
||||
.loader::after {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import styles from "./PulseLoader.module.css";
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PulseLoader({ size = 24, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.loader, className)}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useResetRateLimit } from "../../hooks/useResetRateLimit";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
resetCost: number;
|
||||
resetMessage: string;
|
||||
isWeeklyExhausted?: boolean;
|
||||
hasInsufficientCredits?: boolean;
|
||||
isBillingEnabled?: boolean;
|
||||
onCreditChange?: () => void;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function RateLimitResetDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetCost,
|
||||
resetMessage,
|
||||
isWeeklyExhausted = false,
|
||||
hasInsufficientCredits = false,
|
||||
isBillingEnabled = false,
|
||||
onCreditChange,
|
||||
}: Props) {
|
||||
const { resetUsage, isPending } = useResetRateLimit({
|
||||
onSuccess: onClose,
|
||||
onCreditChange,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// Refresh the credit balance each time the dialog opens so we never
|
||||
// block a valid reset due to a stale client-side balance.
|
||||
useEffect(() => {
|
||||
if (isOpen) onCreditChange?.();
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Whether to hide the reset button entirely
|
||||
const cannotReset = isWeeklyExhausted || hasInsufficientCredits;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Usage limit reached"
|
||||
styling={{ maxWidth: "28rem", minWidth: "auto" }}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Text variant="body">{resetMessage}</Text>
|
||||
{isWeeklyExhausted ? (
|
||||
<Text variant="body">
|
||||
Your weekly limit is also reached, so resetting the daily limit
|
||||
won't help. Please wait for your limits to reset.
|
||||
</Text>
|
||||
) : hasInsufficientCredits ? (
|
||||
<Text variant="body">
|
||||
You don't have enough credits to reset your daily limit.
|
||||
{isBillingEnabled
|
||||
? " Add credits to continue working."
|
||||
: " Please wait for your limits to reset."}
|
||||
</Text>
|
||||
) : (
|
||||
<Text variant="body">
|
||||
You can spend{" "}
|
||||
<Text variant="body-medium" as="span">
|
||||
{formatCents(resetCost)}
|
||||
</Text>{" "}
|
||||
in credits to reset your daily limit and continue working.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Dialog.Footer className="!justify-center">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
{cannotReset ? "OK" : "Wait for reset"}
|
||||
</Button>
|
||||
{hasInsufficientCredits && isBillingEnabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
router.push("/profile/credits");
|
||||
}}
|
||||
>
|
||||
Add credits
|
||||
</Button>
|
||||
)}
|
||||
{!cannotReset && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => resetUsage()}
|
||||
loading={isPending}
|
||||
>
|
||||
Reset for {formatCents(resetCost)}
|
||||
</Button>
|
||||
)}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
|
||||
import { useGetV2GetCopilotUsage } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -20,6 +22,12 @@ export function UsageLimits() {
|
||||
},
|
||||
});
|
||||
|
||||
const isBillingEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT);
|
||||
const { credits, fetchCredits } = useCredits({ fetchInitialCredits: true });
|
||||
const resetCost = usage?.reset_cost;
|
||||
const hasInsufficientCredits =
|
||||
credits !== null && resetCost != null && credits < resetCost;
|
||||
|
||||
if (isLoading || !usage?.daily || !usage?.weekly) return null;
|
||||
if (usage.daily.limit <= 0 && usage.weekly.limit <= 0) return null;
|
||||
|
||||
@@ -31,7 +39,12 @@ export function UsageLimits() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-3">
|
||||
<UsagePanelContent usage={usage} />
|
||||
<UsagePanelContent
|
||||
usage={usage}
|
||||
hasInsufficientCredits={hasInsufficientCredits}
|
||||
isBillingEnabled={isBillingEnabled}
|
||||
onCreditChange={fetchCredits}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import Link from "next/link";
|
||||
import { useResetRateLimit } from "../../hooks/useResetRateLimit";
|
||||
|
||||
export function formatResetTime(
|
||||
resetsAt: Date | string,
|
||||
@@ -70,15 +72,50 @@ function UsageBar({
|
||||
);
|
||||
}
|
||||
|
||||
function ResetButton({
|
||||
cost,
|
||||
onCreditChange,
|
||||
}: {
|
||||
cost: number;
|
||||
onCreditChange?: () => void;
|
||||
}) {
|
||||
const { resetUsage, isPending } = useResetRateLimit({ onCreditChange });
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => resetUsage()}
|
||||
loading={isPending}
|
||||
className="mt-1 w-full text-[11px]"
|
||||
>
|
||||
{isPending
|
||||
? "Resetting..."
|
||||
: `Reset daily limit for $${(cost / 100).toFixed(2)}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsagePanelContent({
|
||||
usage,
|
||||
showBillingLink = true,
|
||||
hasInsufficientCredits = false,
|
||||
isBillingEnabled = false,
|
||||
onCreditChange,
|
||||
}: {
|
||||
usage: CoPilotUsageStatus;
|
||||
showBillingLink?: boolean;
|
||||
hasInsufficientCredits?: boolean;
|
||||
isBillingEnabled?: boolean;
|
||||
onCreditChange?: () => void;
|
||||
}) {
|
||||
const hasDailyLimit = usage.daily.limit > 0;
|
||||
const hasWeeklyLimit = usage.weekly.limit > 0;
|
||||
const isDailyExhausted =
|
||||
hasDailyLimit && usage.daily.used >= usage.daily.limit;
|
||||
const isWeeklyExhausted =
|
||||
hasWeeklyLimit && usage.weekly.used >= usage.weekly.limit;
|
||||
const resetCost = usage.reset_cost ?? 0;
|
||||
|
||||
if (!hasDailyLimit && !hasWeeklyLimit) {
|
||||
return (
|
||||
@@ -105,6 +142,23 @@ export function UsagePanelContent({
|
||||
resetsAt={usage.weekly.resets_at}
|
||||
/>
|
||||
)}
|
||||
{isDailyExhausted &&
|
||||
!isWeeklyExhausted &&
|
||||
resetCost > 0 &&
|
||||
!hasInsufficientCredits && (
|
||||
<ResetButton cost={resetCost} onCreditChange={onCreditChange} />
|
||||
)}
|
||||
{isDailyExhausted &&
|
||||
!isWeeklyExhausted &&
|
||||
hasInsufficientCredits &&
|
||||
isBillingEnabled && (
|
||||
<Link
|
||||
href="/profile/credits"
|
||||
className="mt-1 inline-flex w-full items-center justify-center rounded-md bg-primary px-3 py-1.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Add credits to reset
|
||||
</Link>
|
||||
)}
|
||||
{showBillingLink && (
|
||||
<Link
|
||||
href="/profile/credits"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
/**
|
||||
* Check whether a refetchSession result indicates the backend still has an
|
||||
* active SSE stream for this session.
|
||||
*/
|
||||
export function hasActiveBackendStream(result: { data?: unknown }): boolean {
|
||||
const d = result.data;
|
||||
return (
|
||||
d != null &&
|
||||
typeof d === "object" &&
|
||||
"status" in d &&
|
||||
d.status === 200 &&
|
||||
"data" in d &&
|
||||
d.data != null &&
|
||||
typeof d.data === "object" &&
|
||||
"active_stream" in d.data &&
|
||||
!!d.data.active_stream
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark any in-progress tool parts as completed/errored so spinners stop. */
|
||||
export function resolveInProgressTools(
|
||||
messages: UIMessage[],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
usePostV2ResetCopilotUsage,
|
||||
getGetV2GetCopilotUsageQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import { ApiError } from "@/lib/autogpt-server-api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useResetRateLimit(options?: {
|
||||
onSuccess?: () => void;
|
||||
onCreditChange?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: resetUsage, isPending } = usePostV2ResetCopilotUsage({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
// Await the usage refetch so the UI shows updated limits before
|
||||
// closing the dialog or re-enabling the reset CTA.
|
||||
// invalidateQueries already triggers a refetch for active queries.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetCopilotUsageQueryKey(),
|
||||
});
|
||||
options?.onCreditChange?.();
|
||||
toast({
|
||||
title: "Rate limit reset",
|
||||
description:
|
||||
"Your daily usage limit has been reset. You can continue working.",
|
||||
});
|
||||
options?.onSuccess?.();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? (error.response?.detail ?? error.message)
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Failed to reset limit.";
|
||||
toast({
|
||||
title: "Reset failed",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { resetUsage, isPending };
|
||||
}
|
||||
@@ -54,7 +54,10 @@ export function useCopilotPage() {
|
||||
status,
|
||||
error,
|
||||
isReconnecting,
|
||||
isSyncing,
|
||||
isUserStoppingRef,
|
||||
rateLimitMessage,
|
||||
dismissRateLimit,
|
||||
} = useCopilotStream({
|
||||
sessionId,
|
||||
hydratedMessages,
|
||||
@@ -349,6 +352,7 @@ export function useCopilotPage() {
|
||||
error,
|
||||
stop,
|
||||
isReconnecting,
|
||||
isSyncing,
|
||||
isLoadingSession,
|
||||
isSessionError,
|
||||
isCreatingSession,
|
||||
@@ -373,5 +377,8 @@ export function useCopilotPage() {
|
||||
handleDeleteClick,
|
||||
handleConfirmDelete,
|
||||
handleCancelDelete,
|
||||
// Rate limit reset
|
||||
rateLimitMessage,
|
||||
dismissRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,19 @@ import { useChat } from "@ai-sdk/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { deduplicateMessages, resolveInProgressTools } from "./helpers";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
deduplicateMessages,
|
||||
hasActiveBackendStream,
|
||||
resolveInProgressTools,
|
||||
} from "./helpers";
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
const RECONNECT_MAX_ATTEMPTS = 3;
|
||||
|
||||
/** Minimum time the page must have been hidden to trigger a wake re-sync. */
|
||||
const WAKE_RESYNC_THRESHOLD_MS = 30_000;
|
||||
|
||||
/** Fetch a fresh JWT for direct backend requests (same pattern as WebSocket). */
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const { token, error } = await getWebSocketToken();
|
||||
@@ -40,6 +47,8 @@ export function useCopilotStream({
|
||||
refetchSession,
|
||||
}: UseCopilotStreamArgs) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rateLimitMessage, setRateLimitMessage] = useState<string | null>(null);
|
||||
const dismissRateLimit = useCallback(() => setRateLimitMessage(null), []);
|
||||
|
||||
// Connect directly to the Python backend for SSE, bypassing the Next.js
|
||||
// serverless proxy. This eliminates the Vercel 800s function timeout that
|
||||
@@ -98,6 +107,10 @@ export function useCopilotStream({
|
||||
// Must be state (not ref) so that setting it triggers a re-render and
|
||||
// recomputes `isReconnecting`.
|
||||
const [reconnectExhausted, setReconnectExhausted] = useState(false);
|
||||
// True while performing a wake re-sync (blocks chat input).
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
// Tracks the last time the page was hidden — used to detect sleep/wake gaps.
|
||||
const lastHiddenAtRef = useRef(Date.now());
|
||||
|
||||
function handleReconnect(sid: string) {
|
||||
if (isReconnectScheduledRef.current || !sid) return;
|
||||
@@ -159,19 +172,7 @@ export function useCopilotStream({
|
||||
// unnecessary reconnect cycles.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const result = await refetchSession();
|
||||
const d = result.data;
|
||||
const backendActive =
|
||||
d != null &&
|
||||
typeof d === "object" &&
|
||||
"status" in d &&
|
||||
d.status === 200 &&
|
||||
"data" in d &&
|
||||
d.data != null &&
|
||||
typeof d.data === "object" &&
|
||||
"active_stream" in d.data &&
|
||||
!!d.data.active_stream;
|
||||
|
||||
if (backendActive) {
|
||||
if (hasActiveBackendStream(result)) {
|
||||
handleReconnect(sessionId);
|
||||
}
|
||||
},
|
||||
@@ -197,13 +198,10 @@ export function useCopilotStream({
|
||||
}
|
||||
const isRateLimited = errorDetail.toLowerCase().includes("usage limit");
|
||||
if (isRateLimited) {
|
||||
toast({
|
||||
title: "Usage limit reached",
|
||||
description:
|
||||
errorDetail ||
|
||||
setRateLimitMessage(
|
||||
errorDetail ||
|
||||
"You've reached your usage limit. Please try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -298,6 +296,67 @@ export function useCopilotStream({
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a ref to sessionId so the async wake handler can detect staleness.
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wake detection: when the page becomes visible after being hidden for >30s
|
||||
// (device sleep, tab backgrounded for a long time), refetch the session to
|
||||
// pick up any messages the backend produced while the SSE was dead.
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
async function handleWakeResync() {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
|
||||
const elapsed = Date.now() - lastHiddenAtRef.current;
|
||||
lastHiddenAtRef.current = Date.now();
|
||||
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (elapsed < WAKE_RESYNC_THRESHOLD_MS) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const result = await refetchSession();
|
||||
// Bail out if the session changed while the refetch was in flight.
|
||||
if (sessionIdRef.current !== sid) return;
|
||||
|
||||
if (hasActiveBackendStream(result)) {
|
||||
// Stream is still running — resume SSE to pick up live chunks.
|
||||
// Remove stale in-progress assistant message first (backend replays
|
||||
// from "0-0").
|
||||
setMessages((prev) => {
|
||||
if (prev.length > 0 && prev[prev.length - 1].role === "assistant") {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
await resumeStream();
|
||||
}
|
||||
// If !backendActive, the refetch will update hydratedMessages via
|
||||
// React Query, and the hydration effect below will merge them in.
|
||||
} catch (err) {
|
||||
console.warn("[copilot] wake re-sync failed", err);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
lastHiddenAtRef.current = Date.now();
|
||||
} else {
|
||||
handleWakeResync();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [refetchSession, setMessages, resumeStream]);
|
||||
|
||||
// Hydrate messages from REST API when not actively streaming
|
||||
useEffect(() => {
|
||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||
@@ -319,9 +378,11 @@ export function useCopilotStream({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isReconnectScheduledRef.current = false;
|
||||
setIsReconnectScheduled(false);
|
||||
setRateLimitMessage(null);
|
||||
hasShownDisconnectToast.current = false;
|
||||
isUserStoppingRef.current = false;
|
||||
setReconnectExhausted(false);
|
||||
setIsSyncing(false);
|
||||
hasResumedRef.current.clear();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
@@ -424,6 +485,9 @@ export function useCopilotStream({
|
||||
status,
|
||||
error: isReconnecting || isUserStoppingRef.current ? undefined : error,
|
||||
isReconnecting,
|
||||
isSyncing,
|
||||
isUserStoppingRef,
|
||||
rateLimitMessage,
|
||||
dismissRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,10 @@ export function StoreCard({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-xl bg-violet-50" />
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl"
|
||||
style={{ backgroundColor: "rgb(216, 208, 255)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +116,7 @@ export function StoreCard({
|
||||
|
||||
{/* Third Section: Description */}
|
||||
<div className="mt-2.5 flex w-full flex-col">
|
||||
<Text variant="body" className="line-clamp-2 leading-normal">
|
||||
<Text variant="body" className="line-clamp-3 leading-normal">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts
generated
Normal file
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedPromptsResponse.ts
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.13.0 🍺
|
||||
* Do not edit manually.
|
||||
* AutoGPT Agent Server
|
||||
* This server is used to execute agents that are created by the AutoGPT system.
|
||||
* OpenAPI spec version: 0.1
|
||||
*/
|
||||
import type { SuggestedTheme } from "./suggestedTheme";
|
||||
|
||||
/**
|
||||
* Response model for user-specific suggested prompts grouped by theme.
|
||||
*/
|
||||
export interface SuggestedPromptsResponse {
|
||||
themes: SuggestedTheme[];
|
||||
}
|
||||
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts
generated
Normal file
15
autogpt_platform/frontend/src/app/api/__generated__/models/suggestedTheme.ts
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.13.0 🍺
|
||||
* Do not edit manually.
|
||||
* AutoGPT Agent Server
|
||||
* This server is used to execute agents that are created by the AutoGPT system.
|
||||
* OpenAPI spec version: 0.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* A themed group of suggested prompts.
|
||||
*/
|
||||
export interface SuggestedTheme {
|
||||
name: string;
|
||||
prompts: string[];
|
||||
}
|
||||
@@ -1358,11 +1358,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/suggested-prompts": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Suggested Prompts",
|
||||
"description": "Get LLM-generated suggested prompts grouped by theme.\n\nReturns personalized quick-action prompts based on the user's\nbusiness understanding. Returns empty themes list if no custom\nprompts are available.",
|
||||
"operationId": "getV2GetSuggestedPrompts",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestedPromptsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/chat/usage": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Copilot Usage",
|
||||
"description": "Get CoPilot usage status for the authenticated user.\n\nReturns current token usage vs limits for daily and weekly windows.",
|
||||
"description": "Get CoPilot usage status for the authenticated user.\n\nReturns current token usage vs limits for daily and weekly windows.\nGlobal defaults sourced from LaunchDarkly (falling back to config).",
|
||||
"operationId": "getV2GetCopilotUsage",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -1380,6 +1404,122 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/chat/usage/reset": {
|
||||
"post": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Reset Copilot Usage",
|
||||
"description": "Reset the daily CoPilot rate limit by spending credits.\n\nAllows users who have hit their daily token limit to spend credits\nto reset their daily usage counter and continue working.\nReturns 400 if the feature is disabled or the user is not over the limit.\nReturns 402 if the user has insufficient credits.",
|
||||
"operationId": "postV2ResetCopilotUsage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RateLimitResetResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request (feature disabled or daily limit not reached)"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"402": { "description": "Payment Required (insufficient credits)" },
|
||||
"429": {
|
||||
"description": "Too Many Requests (max daily resets exceeded or reset in progress)"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable (Redis reset failed; credits refunded or support needed)"
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/copilot/admin/rate_limit": {
|
||||
"get": {
|
||||
"tags": ["v2", "admin", "copilot", "admin"],
|
||||
"summary": "Get User Rate Limit",
|
||||
"description": "Get a user's current usage and effective rate limits. Admin-only.",
|
||||
"operationId": "getV2Get user rate limit",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "User Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRateLimitResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/copilot/admin/rate_limit/reset": {
|
||||
"post": {
|
||||
"tags": ["v2", "admin", "copilot", "admin"],
|
||||
"summary": "Reset User Rate Limit Usage",
|
||||
"description": "Reset a user's daily usage counter (and optionally weekly). Admin-only.",
|
||||
"operationId": "postV2Reset user rate limit usage",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_postV2Reset_user_rate_limit_usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRateLimitResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/credits": {
|
||||
"get": {
|
||||
"tags": ["v1", "credits"],
|
||||
@@ -8131,6 +8271,19 @@
|
||||
"type": "object",
|
||||
"title": "Body_postV2Execute a preset"
|
||||
},
|
||||
"Body_postV2Reset_user_rate_limit_usage": {
|
||||
"properties": {
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"reset_weekly": {
|
||||
"type": "boolean",
|
||||
"title": "Reset Weekly",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["user_id"],
|
||||
"title": "Body_postV2Reset user rate limit usage"
|
||||
},
|
||||
"Body_postV2Upload_submission_media": {
|
||||
"properties": {
|
||||
"file": { "type": "string", "format": "binary", "title": "File" }
|
||||
@@ -8249,7 +8402,13 @@
|
||||
"CoPilotUsageStatus": {
|
||||
"properties": {
|
||||
"daily": { "$ref": "#/components/schemas/UsageWindow" },
|
||||
"weekly": { "$ref": "#/components/schemas/UsageWindow" }
|
||||
"weekly": { "$ref": "#/components/schemas/UsageWindow" },
|
||||
"reset_cost": {
|
||||
"type": "integer",
|
||||
"title": "Reset Cost",
|
||||
"description": "Credit cost (in cents) to reset the daily limit. 0 = feature disabled.",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["daily", "weekly"],
|
||||
@@ -11558,6 +11717,34 @@
|
||||
"required": ["providers", "pagination"],
|
||||
"title": "ProviderResponse"
|
||||
},
|
||||
"RateLimitResetResponse": {
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "title": "Success" },
|
||||
"credits_charged": {
|
||||
"type": "integer",
|
||||
"title": "Credits Charged",
|
||||
"description": "Credits charged (in cents)"
|
||||
},
|
||||
"remaining_balance": {
|
||||
"type": "integer",
|
||||
"title": "Remaining Balance",
|
||||
"description": "Credit balance after charge (in cents)"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/components/schemas/CoPilotUsageStatus",
|
||||
"description": "Updated usage status after reset"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success",
|
||||
"credits_charged",
|
||||
"remaining_balance",
|
||||
"usage"
|
||||
],
|
||||
"title": "RateLimitResetResponse",
|
||||
"description": "Response from resetting the daily rate limit."
|
||||
},
|
||||
"RecentExecution": {
|
||||
"properties": {
|
||||
"status": { "type": "string", "title": "Status" },
|
||||
@@ -12754,6 +12941,33 @@
|
||||
"title": "SuggestedGoalResponse",
|
||||
"description": "Response when the goal needs refinement with a suggested alternative."
|
||||
},
|
||||
"SuggestedPromptsResponse": {
|
||||
"properties": {
|
||||
"themes": {
|
||||
"items": { "$ref": "#/components/schemas/SuggestedTheme" },
|
||||
"type": "array",
|
||||
"title": "Themes"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["themes"],
|
||||
"title": "SuggestedPromptsResponse",
|
||||
"description": "Response model for user-specific suggested prompts grouped by theme."
|
||||
},
|
||||
"SuggestedTheme": {
|
||||
"properties": {
|
||||
"name": { "type": "string", "title": "Name" },
|
||||
"prompts": {
|
||||
"items": { "type": "string" },
|
||||
"type": "array",
|
||||
"title": "Prompts"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["name", "prompts"],
|
||||
"title": "SuggestedTheme",
|
||||
"description": "A themed group of suggested prompts."
|
||||
},
|
||||
"SuggestionsResponse": {
|
||||
"properties": {
|
||||
"recent_searches": {
|
||||
@@ -14482,6 +14696,36 @@
|
||||
"required": ["provider", "username", "password"],
|
||||
"title": "UserPasswordCredentials"
|
||||
},
|
||||
"UserRateLimitResponse": {
|
||||
"properties": {
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"daily_token_limit": {
|
||||
"type": "integer",
|
||||
"title": "Daily Token Limit"
|
||||
},
|
||||
"weekly_token_limit": {
|
||||
"type": "integer",
|
||||
"title": "Weekly Token Limit"
|
||||
},
|
||||
"daily_tokens_used": {
|
||||
"type": "integer",
|
||||
"title": "Daily Tokens Used"
|
||||
},
|
||||
"weekly_tokens_used": {
|
||||
"type": "integer",
|
||||
"title": "Weekly Tokens Used"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"user_id",
|
||||
"daily_token_limit",
|
||||
"weekly_token_limit",
|
||||
"daily_tokens_used",
|
||||
"weekly_tokens_used"
|
||||
],
|
||||
"title": "UserRateLimitResponse"
|
||||
},
|
||||
"UserReadiness": {
|
||||
"properties": {
|
||||
"has_all_credentials": {
|
||||
|
||||
@@ -16,19 +16,13 @@ export default function ArrayFieldItemTemplate(
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center">
|
||||
<div className="shrink grow">
|
||||
<div className="shrink grow">{children}</div>
|
||||
<div className="mb-4 flex flex-col">
|
||||
<div className="w-full">{children}</div>
|
||||
{hasToolbar && (
|
||||
<div className="-mt-2 flex justify-start gap-2">
|
||||
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
{hasToolbar && (
|
||||
<div className="-mt-4 mb-2 flex gap-2">
|
||||
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,11 +80,6 @@ Root (type: object)
|
||||
│ └── FieldTemplate → AnyOfField
|
||||
│ └── String → TextWidget OR Null → nothing
|
||||
│
|
||||
├── placeholder_values (array of strings)
|
||||
│ └── FieldTemplate → ArrayFieldTemplate
|
||||
│ └── ArrayFieldItemTemplate (per item)
|
||||
│ └── TextWidget
|
||||
│
|
||||
├── advanced (boolean)
|
||||
│ └── FieldTemplate → CheckboxWidget
|
||||
│
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { FormRendererStory, storyDecorator } from "./FormRendererStoryWrapper";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Renderers/FormRenderer/Array Fields",
|
||||
tags: ["autodocs"],
|
||||
decorators: [storyDecorator],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Array field types: list[str], list[int], list[Enum], list[bool], and list[object].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ListOfStrings: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
title: "Tags",
|
||||
items: { type: "string" },
|
||||
description: "list[str] - A list of text items",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ tags: ["tag1", "tag2"] }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ListOfIntegers: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
numbers: {
|
||||
type: "array",
|
||||
title: "Numbers",
|
||||
items: { type: "integer" },
|
||||
description: "list[int] - A list of integers",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ numbers: [1, 2, 3] }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ListOfEnums: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
formats: {
|
||||
type: "array",
|
||||
title: "Formats",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["markdown", "html", "screenshot", "rawHtml", "links"],
|
||||
},
|
||||
description: "list[Enum] - e.g. Firecrawl ScrapeFormat",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ formats: ["markdown", "screenshot"] }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ListOfBooleans: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
flags: {
|
||||
type: "array",
|
||||
title: "Flags",
|
||||
items: { type: "boolean" },
|
||||
description: "list[bool] - A list of boolean flags",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ flags: [true, false] }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ListOfObjects: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
headers: {
|
||||
type: "array",
|
||||
title: "Headers",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", title: "Key" },
|
||||
value: { type: "string", title: "Value" },
|
||||
},
|
||||
},
|
||||
description: "list[dict] - Key-value pairs",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{
|
||||
headers: [{ key: "Authorization", value: "Bearer token" }],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,593 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import { FormRendererStory, storyDecorator } from "./FormRendererStoryWrapper";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Renderers/FormRenderer/Complex Schemas",
|
||||
tags: ["autodocs"],
|
||||
decorators: [storyDecorator],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Complex schemas: nested objects, unions/anyOf, oneOf discriminated unions, multi-select, required fields, and kitchen sink.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// --- Object / Nested Types ---
|
||||
|
||||
export const ObjectField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
config: {
|
||||
type: "object",
|
||||
title: "Config",
|
||||
properties: {
|
||||
host: { type: "string", title: "Host" },
|
||||
port: { type: "integer", title: "Port" },
|
||||
ssl: { type: "boolean", title: "SSL" },
|
||||
},
|
||||
description: "A nested object with multiple fields",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const NestedObjectWithEnum: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
title: "Settings",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
title: "Mode",
|
||||
enum: ["fast", "balanced", "quality"],
|
||||
},
|
||||
max_retries: { type: "integer", title: "Max Retries" },
|
||||
verbose: { type: "boolean", title: "Verbose" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Optional / AnyOf ---
|
||||
|
||||
export const OptionalString: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
nickname: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
title: "Nickname",
|
||||
description: "Optional[str] - can be a string or null",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const OptionalInteger: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
max_tokens: {
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
title: "Max Tokens",
|
||||
description: "Optional[int] - can be an integer or null",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Union / AnyOf (multiple types) ---
|
||||
|
||||
export const UnionStringOrInteger: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
anyOf: [{ type: "string" }, { type: "integer" }],
|
||||
title: "Value",
|
||||
description: "str | int - Union of string and integer",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- TwitterGetUserBlock (exact schema from model_json_schema(), minus credentials) ---
|
||||
|
||||
// Schema uses custom properties (advanced, secret, placeholder) that the backend
|
||||
// emits but aren't part of JSONSchema7, so we keep the object untyped and cast
|
||||
// to RJSFSchema at the component boundary.
|
||||
const twitterGetUserSchema = {
|
||||
type: "object",
|
||||
$defs: {
|
||||
UserId: {
|
||||
type: "object",
|
||||
title: "UserId",
|
||||
properties: {
|
||||
discriminator: {
|
||||
const: "user_id",
|
||||
title: "Discriminator",
|
||||
type: "string",
|
||||
},
|
||||
user_id: {
|
||||
advanced: true,
|
||||
default: "",
|
||||
description: "The ID of the user to lookup",
|
||||
secret: false,
|
||||
title: "User Id",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["discriminator"],
|
||||
},
|
||||
Username: {
|
||||
type: "object",
|
||||
title: "Username",
|
||||
properties: {
|
||||
discriminator: {
|
||||
const: "username",
|
||||
title: "Discriminator",
|
||||
type: "string",
|
||||
},
|
||||
username: {
|
||||
advanced: true,
|
||||
default: "",
|
||||
description: "The Twitter username (handle) of the user",
|
||||
secret: false,
|
||||
title: "Username",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["discriminator"],
|
||||
},
|
||||
UserExpansionsFilter: {
|
||||
type: "object",
|
||||
title: "UserExpansionsFilter",
|
||||
properties: {
|
||||
pinned_tweet_id: {
|
||||
default: false,
|
||||
title: "Pinned Tweet Id",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
TweetFieldsFilter: {
|
||||
type: "object",
|
||||
title: "TweetFieldsFilter",
|
||||
properties: {
|
||||
Tweet_Attachments: {
|
||||
default: false,
|
||||
title: "Tweet Attachments",
|
||||
type: "boolean",
|
||||
},
|
||||
Author_ID: {
|
||||
default: false,
|
||||
title: "Author Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Context_Annotations: {
|
||||
default: false,
|
||||
title: "Context Annotations",
|
||||
type: "boolean",
|
||||
},
|
||||
Conversation_ID: {
|
||||
default: false,
|
||||
title: "Conversation Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Creation_Time: {
|
||||
default: false,
|
||||
title: "Creation Time",
|
||||
type: "boolean",
|
||||
},
|
||||
Edit_Controls: {
|
||||
default: false,
|
||||
title: "Edit Controls",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_Entities: {
|
||||
default: false,
|
||||
title: "Tweet Entities",
|
||||
type: "boolean",
|
||||
},
|
||||
Geographic_Location: {
|
||||
default: false,
|
||||
title: "Geographic Location",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_ID: {
|
||||
default: false,
|
||||
title: "Tweet Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Reply_To_User_ID: {
|
||||
default: false,
|
||||
title: "Reply To User Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Language: {
|
||||
default: false,
|
||||
title: "Language",
|
||||
type: "boolean",
|
||||
},
|
||||
Public_Metrics: {
|
||||
default: false,
|
||||
title: "Public Metrics",
|
||||
type: "boolean",
|
||||
},
|
||||
Sensitive_Content_Flag: {
|
||||
default: false,
|
||||
title: "Sensitive Content Flag",
|
||||
type: "boolean",
|
||||
},
|
||||
Referenced_Tweets: {
|
||||
default: false,
|
||||
title: "Referenced Tweets",
|
||||
type: "boolean",
|
||||
},
|
||||
Reply_Settings: {
|
||||
default: false,
|
||||
title: "Reply Settings",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_Source: {
|
||||
default: false,
|
||||
title: "Tweet Source",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_Text: {
|
||||
default: false,
|
||||
title: "Tweet Text",
|
||||
type: "boolean",
|
||||
},
|
||||
Withheld_Content: {
|
||||
default: false,
|
||||
title: "Withheld Content",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
TweetUserFieldsFilter: {
|
||||
type: "object",
|
||||
title: "TweetUserFieldsFilter",
|
||||
properties: {
|
||||
Account_Creation_Date: {
|
||||
default: false,
|
||||
title: "Account Creation Date",
|
||||
type: "boolean",
|
||||
},
|
||||
User_Bio: {
|
||||
default: false,
|
||||
title: "User Bio",
|
||||
type: "boolean",
|
||||
},
|
||||
User_Entities: {
|
||||
default: false,
|
||||
title: "User Entities",
|
||||
type: "boolean",
|
||||
},
|
||||
User_ID: {
|
||||
default: false,
|
||||
title: "User Id",
|
||||
type: "boolean",
|
||||
},
|
||||
User_Location: {
|
||||
default: false,
|
||||
title: "User Location",
|
||||
type: "boolean",
|
||||
},
|
||||
Latest_Tweet_ID: {
|
||||
default: false,
|
||||
title: "Latest Tweet Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Display_Name: {
|
||||
default: false,
|
||||
title: "Display Name",
|
||||
type: "boolean",
|
||||
},
|
||||
Pinned_Tweet_ID: {
|
||||
default: false,
|
||||
title: "Pinned Tweet Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Profile_Picture_URL: {
|
||||
default: false,
|
||||
title: "Profile Picture Url",
|
||||
type: "boolean",
|
||||
},
|
||||
Is_Protected_Account: {
|
||||
default: false,
|
||||
title: "Is Protected Account",
|
||||
type: "boolean",
|
||||
},
|
||||
Account_Statistics: {
|
||||
default: false,
|
||||
title: "Account Statistics",
|
||||
type: "boolean",
|
||||
},
|
||||
Profile_URL: {
|
||||
default: false,
|
||||
title: "Profile Url",
|
||||
type: "boolean",
|
||||
},
|
||||
Username: {
|
||||
default: false,
|
||||
title: "Username",
|
||||
type: "boolean",
|
||||
},
|
||||
Is_Verified: {
|
||||
default: false,
|
||||
title: "Is Verified",
|
||||
type: "boolean",
|
||||
},
|
||||
Verification_Type: {
|
||||
default: false,
|
||||
title: "Verification Type",
|
||||
type: "boolean",
|
||||
},
|
||||
Content_Withholding_Info: {
|
||||
default: false,
|
||||
title: "Content Withholding Info",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["identifier"],
|
||||
properties: {
|
||||
identifier: {
|
||||
advanced: false,
|
||||
description:
|
||||
"Choose whether to identify the user by their unique Twitter ID or by their username",
|
||||
discriminator: {
|
||||
mapping: {
|
||||
user_id: "#/$defs/UserId",
|
||||
username: "#/$defs/Username",
|
||||
},
|
||||
propertyName: "discriminator",
|
||||
},
|
||||
oneOf: [{ $ref: "#/$defs/UserId" }, { $ref: "#/$defs/Username" }],
|
||||
secret: false,
|
||||
title: "Identifier",
|
||||
},
|
||||
expansions: {
|
||||
advanced: true,
|
||||
anyOf: [{ $ref: "#/$defs/UserExpansionsFilter" }, { type: "null" }],
|
||||
default: null,
|
||||
description:
|
||||
"Choose what extra information you want to get with user data. Currently only 'pinned_tweet_id' is available to see a user's pinned tweet.",
|
||||
placeholder: "Select extra user information to include",
|
||||
secret: false,
|
||||
},
|
||||
tweet_fields: {
|
||||
advanced: true,
|
||||
anyOf: [{ $ref: "#/$defs/TweetFieldsFilter" }, { type: "null" }],
|
||||
default: null,
|
||||
description:
|
||||
"Select what tweet information you want to see in pinned tweets. This only works if you select 'pinned_tweet_id' in expansions above.",
|
||||
placeholder: "Choose what details to see in pinned tweets",
|
||||
secret: false,
|
||||
},
|
||||
user_fields: {
|
||||
advanced: true,
|
||||
anyOf: [{ $ref: "#/$defs/TweetUserFieldsFilter" }, { type: "null" }],
|
||||
default: null,
|
||||
description:
|
||||
"Select what user information you want to see, like username, bio, profile picture, etc.",
|
||||
placeholder: "Choose what user details you want to see",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TwitterGetUserBlock: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={twitterGetUserSchema as RJSFSchema}
|
||||
initialValues={{
|
||||
identifier: { discriminator: "user_id", user_id: "" },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Multi-select (all-boolean object, exact Twitter TweetFieldsFilter schema) ---
|
||||
|
||||
const multiSelectSchema = {
|
||||
type: "object",
|
||||
$defs: {
|
||||
TweetFieldsFilter: {
|
||||
type: "object",
|
||||
title: "TweetFieldsFilter",
|
||||
properties: {
|
||||
Tweet_Attachments: {
|
||||
default: false,
|
||||
title: "Tweet Attachments",
|
||||
type: "boolean",
|
||||
},
|
||||
Author_ID: {
|
||||
default: false,
|
||||
title: "Author Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Context_Annotations: {
|
||||
default: false,
|
||||
title: "Context Annotations",
|
||||
type: "boolean",
|
||||
},
|
||||
Conversation_ID: {
|
||||
default: false,
|
||||
title: "Conversation Id",
|
||||
type: "boolean",
|
||||
},
|
||||
Creation_Time: {
|
||||
default: false,
|
||||
title: "Creation Time",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_Entities: {
|
||||
default: false,
|
||||
title: "Tweet Entities",
|
||||
type: "boolean",
|
||||
},
|
||||
Language: {
|
||||
default: false,
|
||||
title: "Language",
|
||||
type: "boolean",
|
||||
},
|
||||
Public_Metrics: {
|
||||
default: false,
|
||||
title: "Public Metrics",
|
||||
type: "boolean",
|
||||
},
|
||||
Tweet_Text: {
|
||||
default: false,
|
||||
title: "Tweet Text",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
tweet_fields: {
|
||||
anyOf: [{ $ref: "#/$defs/TweetFieldsFilter" }, { type: "null" }],
|
||||
default: null,
|
||||
description: "Select what tweet information you want to see.",
|
||||
placeholder: "Choose what details to see in tweets",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelectField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory jsonSchema={multiSelectSchema as RJSFSchema} />
|
||||
),
|
||||
};
|
||||
|
||||
// --- Required vs Optional fields ---
|
||||
|
||||
export const RequiredFields: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
required: ["email", "role"],
|
||||
properties: {
|
||||
email: { type: "string", title: "Email" },
|
||||
role: {
|
||||
type: "string",
|
||||
title: "Role",
|
||||
enum: ["admin", "editor", "viewer"],
|
||||
},
|
||||
bio: { type: "string", title: "Bio" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Kitchen Sink ---
|
||||
|
||||
export const KitchenSink: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
required: ["url", "method"],
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
title: "URL",
|
||||
description: "The target URL",
|
||||
},
|
||||
method: {
|
||||
type: "string",
|
||||
title: "Method",
|
||||
enum: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
title: "Timeout (seconds)",
|
||||
description: "Request timeout",
|
||||
},
|
||||
follow_redirects: {
|
||||
type: "boolean",
|
||||
title: "Follow Redirects",
|
||||
},
|
||||
headers: {
|
||||
type: "array",
|
||||
title: "Headers",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", title: "Key" },
|
||||
value: { type: "string", title: "Value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
body_format: {
|
||||
type: "string",
|
||||
title: "Body Format",
|
||||
enum: ["json", "form", "raw", "none"],
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
title: "Tags",
|
||||
items: { type: "string" },
|
||||
},
|
||||
auth: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
title: "Bearer Token",
|
||||
properties: {
|
||||
token: { type: "string", title: "Token" },
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
title: "Authentication",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{
|
||||
method: "GET",
|
||||
follow_redirects: true,
|
||||
body_format: "json",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { FormRenderer } from "../FormRenderer";
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import type { ExtendedFormContextType } from "../types";
|
||||
|
||||
const defaultFormContext: ExtendedFormContextType = {
|
||||
nodeId: "story-node",
|
||||
showHandles: false,
|
||||
size: "medium",
|
||||
showOptionalToggle: true,
|
||||
};
|
||||
|
||||
export function FormRendererStory({
|
||||
jsonSchema,
|
||||
initialValues,
|
||||
}: {
|
||||
jsonSchema: RJSFSchema;
|
||||
initialValues?: Record<string, unknown>;
|
||||
}) {
|
||||
const [formData, setFormData] = useState(initialValues ?? {});
|
||||
|
||||
return (
|
||||
<div className="w-[400px]">
|
||||
<FormRenderer
|
||||
jsonSchema={jsonSchema}
|
||||
handleChange={(e) => setFormData(e.formData ?? {})}
|
||||
uiSchema={{}}
|
||||
initialValues={formData}
|
||||
formContext={defaultFormContext}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function storyDecorator(Story: React.ComponentType) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { FormRendererStory, storyDecorator } from "./FormRendererStoryWrapper";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Renderers/FormRenderer/Primitive Fields",
|
||||
tags: ["autodocs"],
|
||||
decorators: [storyDecorator],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Primitive field types: strings, numbers, booleans, enums, and date/time formats.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// --- String ---
|
||||
|
||||
export const StringField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name", description: "Enter a name" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const StringWithDefault: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
greeting: {
|
||||
type: "string",
|
||||
title: "Greeting",
|
||||
default: "Hello, world!",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ greeting: "Hello, world!" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Number ---
|
||||
|
||||
export const IntegerField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", title: "Count", description: "A number" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const NumberField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
temperature: {
|
||||
type: "number",
|
||||
title: "Temperature",
|
||||
description: "A float value",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const NumberWithConstraints: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
score: {
|
||||
type: "number",
|
||||
title: "Score",
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description: "Value between 0 and 100",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Boolean ---
|
||||
|
||||
export const BooleanField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
title: "Enabled",
|
||||
description: "Toggle this on or off",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Enum ---
|
||||
|
||||
export const EnumField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
color: {
|
||||
type: "string",
|
||||
title: "Color",
|
||||
enum: ["red", "green", "blue", "yellow"],
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const EnumWithDefault: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
priority: {
|
||||
type: "string",
|
||||
title: "Priority",
|
||||
enum: ["low", "medium", "high", "critical"],
|
||||
default: "medium",
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialValues={{ priority: "medium" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Date / Time ---
|
||||
|
||||
export const DateField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
title: "Start Date",
|
||||
format: "date",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const TimeField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
alarm_time: {
|
||||
type: "string",
|
||||
title: "Alarm Time",
|
||||
format: "time",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const DateTimeField: Story = {
|
||||
render: () => (
|
||||
<FormRendererStory
|
||||
jsonSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
scheduled_at: {
|
||||
type: "string",
|
||||
title: "Scheduled At",
|
||||
format: "date-time",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -964,6 +964,7 @@ export type AddUserCreditsResponse = {
|
||||
new_balance: number;
|
||||
transaction_key: string;
|
||||
};
|
||||
|
||||
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
|
||||
date: DataType.DATE,
|
||||
time: DataType.TIME,
|
||||
|
||||
@@ -342,7 +342,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
||||
| [Post To X](block-integrations/ayrshare/post_to_x.md#post-to-x) | Post to X / Twitter using Ayrshare |
|
||||
| [Post To YouTube](block-integrations/ayrshare/post_to_youtube.md#post-to-youtube) | Post to YouTube using Ayrshare |
|
||||
| [Publish To Medium](block-integrations/misc.md#publish-to-medium) | Publishes a post to Medium |
|
||||
| [Read Discord Messages](block-integrations/discord/bot_blocks.md#read-discord-messages) | Reads messages from a Discord channel using a bot token |
|
||||
| [Read Discord Messages](block-integrations/discord/bot_blocks.md#read-discord-messages) | Reads new messages from a Discord channel using a bot token and triggers when a new message is posted |
|
||||
| [Reddit Get My Posts](block-integrations/misc.md#reddit-get-my-posts) | Fetch posts created by the authenticated Reddit user (you) |
|
||||
| [Reply To Discord Message](block-integrations/discord/bot_blocks.md#reply-to-discord-message) | Replies to a specific Discord message |
|
||||
| [Reply To Reddit Comment](block-integrations/misc.md#reply-to-reddit-comment) | Reply to a specific Reddit comment |
|
||||
|
||||
@@ -182,9 +182,9 @@ This is ideal when you want to constrain user input to a predefined set of choic
|
||||
| value | Text selected from a dropdown. | str | No |
|
||||
| title | The title of the input. | str | No |
|
||||
| description | The description of the input. | str | No |
|
||||
| placeholder_values | Possible values for the dropdown. | List[Any] | No |
|
||||
| advanced | Whether to show the input in the advanced section, if the field is not required. | bool | No |
|
||||
| secret | Whether the input should be treated as a secret. | bool | No |
|
||||
| placeholder_values | Possible values for the dropdown. | List[Any] | No |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -293,7 +293,7 @@ A block that accepts and processes user input values within a workflow, supporti
|
||||
|
||||
### How it works
|
||||
<!-- MANUAL: how_it_works -->
|
||||
It accepts a value from the user, along with metadata such as name, description, and optional placeholder values. The block then outputs the provided value.
|
||||
It accepts a value from the user, along with metadata such as name and description. The block then outputs the provided value.
|
||||
<!-- END MANUAL -->
|
||||
|
||||
### Inputs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Read Discord Messages
|
||||
|
||||
### What it is
|
||||
A block that reads messages from a Discord channel using a bot token.
|
||||
A block that reads new messages from a Discord channel using a bot token and triggers when a new message is posted.
|
||||
|
||||
### What it does
|
||||
This block connects to Discord using a bot token and retrieves messages from a specified channel. It can operate continuously or retrieve a single message.
|
||||
|
||||
@@ -132,7 +132,7 @@ The user must be visible to your bot (share a server with your bot).
|
||||
## Read Discord Messages
|
||||
|
||||
### What it is
|
||||
Reads messages from a Discord channel using a bot token.
|
||||
Reads new messages from a Discord channel using a bot token and triggers when a new message is posted
|
||||
|
||||
### How it works
|
||||
<!-- MANUAL: how_it_works -->
|
||||
|
||||
BIN
test-screenshots/pr-12574/01-copilot-landing.png
Normal file
BIN
test-screenshots/pr-12574/01-copilot-landing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
BIN
test-screenshots/pr-12574/19-text-reversal-chat.png
Normal file
BIN
test-screenshots/pr-12574/19-text-reversal-chat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
BIN
test-screenshots/pr-12574/21-followup-sent.png
Normal file
BIN
test-screenshots/pr-12574/21-followup-sent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
test-screenshots/pr-12574/22-followup-response.png
Normal file
BIN
test-screenshots/pr-12574/22-followup-response.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
test-screenshots/pr-12574/25-final-chat-state.png
Normal file
BIN
test-screenshots/pr-12574/25-final-chat-state.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Reference in New Issue
Block a user