mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-12 16:48:06 -05:00
Compare commits
61 Commits
hotfix/wai
...
fix/sql-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224411abd3 | ||
|
|
6b241af79e | ||
|
|
320fb7d83a | ||
|
|
54552248f7 | ||
|
|
d8a5780ea2 | ||
|
|
377657f8a1 | ||
|
|
ff71c940c9 | ||
|
|
9967b3a7ce | ||
|
|
9db443960a | ||
|
|
9316100864 | ||
|
|
cbe0cee0fc | ||
|
|
7cbb1ed859 | ||
|
|
e06e7ff33f | ||
|
|
acb946801b | ||
|
|
48ff225837 | ||
|
|
e2a9923f30 | ||
|
|
39792d517e | ||
|
|
a6a2f71458 | ||
|
|
788b861bb7 | ||
|
|
e203e65dc4 | ||
|
|
bd03697ff2 | ||
|
|
efd37b7a36 | ||
|
|
bb0b45d7f7 | ||
|
|
04df981115 | ||
|
|
d25997b4f2 | ||
|
|
11d55f6055 | ||
|
|
063dc5cf65 | ||
|
|
b7646f3e58 | ||
|
|
0befaf0a47 | ||
|
|
93f58dec5e | ||
|
|
3da595f599 | ||
|
|
e5e60921a3 | ||
|
|
90af8f8e1a | ||
|
|
eba67e0a4b | ||
|
|
47bb89caeb | ||
|
|
271a520afa | ||
|
|
3988057032 | ||
|
|
a6c6e48f00 | ||
|
|
e72ce2f9e7 | ||
|
|
bd7a79a920 | ||
|
|
3f546ae845 | ||
|
|
c958c95d6b | ||
|
|
3e50cbd2cb | ||
|
|
1b69f1644d | ||
|
|
d9035a233c | ||
|
|
972cbfc3de | ||
|
|
8f861b1bb2 | ||
|
|
fa2731bb8b | ||
|
|
2dc0c97a52 | ||
|
|
a1d9b45238 | ||
|
|
29895c290f | ||
|
|
1ed224d481 | ||
|
|
3b5d919399 | ||
|
|
3c16de22ef | ||
|
|
3ed1c93ec0 | ||
|
|
773f545cfd | ||
|
|
84ad4a9f95 | ||
|
|
8610118ddc | ||
|
|
ebb4ebb025 | ||
|
|
cb532e1c4d | ||
|
|
794aee25ab |
94
.github/copilot-instructions.md
vendored
94
.github/copilot-instructions.md
vendored
@@ -12,6 +12,7 @@ This file provides comprehensive onboarding information for GitHub Copilot codin
|
||||
- **Infrastructure** - Docker configurations, CI/CD, and development tools
|
||||
|
||||
**Primary Languages & Frameworks:**
|
||||
|
||||
- **Backend**: Python 3.10-3.13, FastAPI, Prisma ORM, PostgreSQL, RabbitMQ
|
||||
- **Frontend**: TypeScript, Next.js 15, React, Tailwind CSS, Radix UI
|
||||
- **Development**: Docker, Poetry, pnpm, Playwright, Storybook
|
||||
@@ -23,15 +24,17 @@ This file provides comprehensive onboarding information for GitHub Copilot codin
|
||||
**Always run these commands in the correct directory and in this order:**
|
||||
|
||||
1. **Initial Setup** (required once):
|
||||
|
||||
```bash
|
||||
# Clone and enter repository
|
||||
git clone <repo> && cd AutoGPT
|
||||
|
||||
|
||||
# Start all services (database, redis, rabbitmq, clamav)
|
||||
cd autogpt_platform && docker compose --profile local up deps --build --detach
|
||||
```
|
||||
|
||||
2. **Backend Setup** (always run before backend development):
|
||||
|
||||
```bash
|
||||
cd autogpt_platform/backend
|
||||
poetry install # Install dependencies
|
||||
@@ -48,6 +51,7 @@ This file provides comprehensive onboarding information for GitHub Copilot codin
|
||||
### Runtime Requirements
|
||||
|
||||
**Critical:** Always ensure Docker services are running before starting development:
|
||||
|
||||
```bash
|
||||
cd autogpt_platform && docker compose --profile local up deps --build --detach
|
||||
```
|
||||
@@ -58,6 +62,7 @@ cd autogpt_platform && docker compose --profile local up deps --build --detach
|
||||
### Development Commands
|
||||
|
||||
**Backend Development:**
|
||||
|
||||
```bash
|
||||
cd autogpt_platform/backend
|
||||
poetry run serve # Start development server (port 8000)
|
||||
@@ -68,6 +73,7 @@ poetry run lint # Lint code (ruff) - run after format
|
||||
```
|
||||
|
||||
**Frontend Development:**
|
||||
|
||||
```bash
|
||||
cd autogpt_platform/frontend
|
||||
pnpm dev # Start development server (port 3000) - use for active development
|
||||
@@ -81,23 +87,27 @@ pnpm storybook # Start component development server
|
||||
### Testing Strategy
|
||||
|
||||
**Backend Tests:**
|
||||
|
||||
- **Block Tests**: `poetry run pytest backend/blocks/test/test_block.py -xvs` (validates all blocks)
|
||||
- **Specific Block**: `poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[BlockName]' -xvs`
|
||||
- **Snapshot Tests**: Use `--snapshot-update` when output changes, always review with `git diff`
|
||||
|
||||
**Frontend Tests:**
|
||||
|
||||
- **E2E Tests**: Always run `pnpm dev` before `pnpm test` (Playwright requires running instance)
|
||||
- **Component Tests**: Use Storybook for isolated component development
|
||||
|
||||
### Critical Validation Steps
|
||||
|
||||
**Before committing changes:**
|
||||
|
||||
1. Run `poetry run format` (backend) and `pnpm format` (frontend)
|
||||
2. Ensure all tests pass in modified areas
|
||||
3. Verify Docker services are still running
|
||||
4. Check that database migrations apply cleanly
|
||||
|
||||
**Common Issues & Workarounds:**
|
||||
|
||||
- **Prisma issues**: Run `poetry run prisma generate` after schema changes
|
||||
- **Permission errors**: Ensure Docker has proper permissions
|
||||
- **Port conflicts**: Check the `docker-compose.yml` file for the current list of exposed ports. You can list all mapped ports with:
|
||||
@@ -108,6 +118,7 @@ pnpm storybook # Start component development server
|
||||
### Core Architecture
|
||||
|
||||
**AutoGPT Platform** (`autogpt_platform/`):
|
||||
|
||||
- `backend/` - FastAPI server with async support
|
||||
- `backend/backend/` - Core API logic
|
||||
- `backend/blocks/` - Agent execution blocks
|
||||
@@ -121,6 +132,7 @@ pnpm storybook # Start component development server
|
||||
- `docker-compose.yml` - Development stack orchestration
|
||||
|
||||
**Key Configuration Files:**
|
||||
|
||||
- `pyproject.toml` - Python dependencies and tooling
|
||||
- `package.json` - Node.js dependencies and scripts
|
||||
- `schema.prisma` - Database schema and migrations
|
||||
@@ -136,6 +148,7 @@ pnpm storybook # Start component development server
|
||||
### Development Workflow
|
||||
|
||||
**GitHub Actions**: Multiple CI/CD workflows in `.github/workflows/`
|
||||
|
||||
- `platform-backend-ci.yml` - Backend testing and validation
|
||||
- `platform-frontend-ci.yml` - Frontend testing and validation
|
||||
- `platform-fullstack-ci.yml` - End-to-end integration tests
|
||||
@@ -146,11 +159,13 @@ pnpm storybook # Start component development server
|
||||
### Key Source Files
|
||||
|
||||
**Backend Entry Points:**
|
||||
|
||||
- `backend/backend/server/server.py` - FastAPI application setup
|
||||
- `backend/backend/data/` - Database models and user management
|
||||
- `backend/blocks/` - Agent execution blocks and logic
|
||||
|
||||
**Frontend Entry Points:**
|
||||
|
||||
- `frontend/src/app/layout.tsx` - Root application layout
|
||||
- `frontend/src/app/page.tsx` - Home page
|
||||
- `frontend/src/lib/supabase/` - Authentication and database client
|
||||
@@ -160,6 +175,7 @@ pnpm storybook # Start component development server
|
||||
### Agent Block System
|
||||
|
||||
Agents are built using a visual block-based system where each block performs a single action. Blocks are defined in `backend/blocks/` and must include:
|
||||
|
||||
- Block definition with input/output schemas
|
||||
- Execution logic with proper error handling
|
||||
- Tests validating functionality
|
||||
@@ -167,6 +183,7 @@ Agents are built using a visual block-based system where each block performs a s
|
||||
### Database & ORM
|
||||
|
||||
**Prisma ORM** with PostgreSQL backend including pgvector for embeddings:
|
||||
|
||||
- Schema in `schema.prisma`
|
||||
- Migrations in `backend/migrations/`
|
||||
- Always run `prisma migrate dev` and `prisma generate` after schema changes
|
||||
@@ -174,13 +191,15 @@ Agents are built using a visual block-based system where each block performs a s
|
||||
## Environment Configuration
|
||||
|
||||
### Configuration Files Priority Order
|
||||
|
||||
1. **Backend**: `/backend/.env.default` → `/backend/.env` (user overrides)
|
||||
2. **Frontend**: `/frontend/.env.default` → `/frontend/.env` (user overrides)
|
||||
2. **Frontend**: `/frontend/.env.default` → `/frontend/.env` (user overrides)
|
||||
3. **Platform**: `/.env.default` (Supabase/shared) → `/.env` (user overrides)
|
||||
4. Docker Compose `environment:` sections override file-based config
|
||||
5. Shell environment variables have highest precedence
|
||||
|
||||
### Docker Environment Setup
|
||||
|
||||
- All services use hardcoded defaults (no `${VARIABLE}` substitutions)
|
||||
- The `env_file` directive loads variables INTO containers at runtime
|
||||
- Backend/Frontend services use YAML anchors for consistent configuration
|
||||
@@ -189,6 +208,7 @@ Agents are built using a visual block-based system where each block performs a s
|
||||
## Advanced Development Patterns
|
||||
|
||||
### Adding New Blocks
|
||||
|
||||
1. Create file in `/backend/backend/blocks/`
|
||||
2. Inherit from `Block` base class with input/output schemas
|
||||
3. Implement `run` method with proper error handling
|
||||
@@ -198,6 +218,7 @@ Agents are built using a visual block-based system where each block performs a s
|
||||
7. Consider how inputs/outputs connect with other blocks in graph editor
|
||||
|
||||
### API Development
|
||||
|
||||
1. Update routes in `/backend/backend/server/routers/`
|
||||
2. Add/update Pydantic models in same directory
|
||||
3. Write tests alongside route files
|
||||
@@ -205,21 +226,76 @@ Agents are built using a visual block-based system where each block performs a s
|
||||
5. Run `poetry run test` to verify changes
|
||||
|
||||
### Frontend Development
|
||||
1. Components in `/frontend/src/components/`
|
||||
2. Use existing UI components from `/frontend/src/components/ui/`
|
||||
3. Add Storybook stories for component development
|
||||
4. Test user-facing features with Playwright E2E tests
|
||||
5. Update protected routes in middleware when needed
|
||||
|
||||
**📖 Complete Frontend Guide**: See `autogpt_platform/frontend/CONTRIBUTING.md` and `autogpt_platform/frontend/.cursorrules` for comprehensive patterns and conventions.
|
||||
|
||||
**Quick Reference:**
|
||||
|
||||
**Component Structure:**
|
||||
|
||||
- Separate render logic from data/behavior
|
||||
- Structure: `ComponentName/ComponentName.tsx` + `useComponentName.ts` + `helpers.ts`
|
||||
- Exception: Small components (3-4 lines of logic) can be inline
|
||||
- Render-only components can be direct files without folders
|
||||
|
||||
**Data Fetching:**
|
||||
|
||||
- Use generated API hooks from `@/app/api/__generated__/endpoints/`
|
||||
- Generated via Orval from backend OpenAPI spec
|
||||
- Pattern: `use{Method}{Version}{OperationName}`
|
||||
- Example: `useGetV2ListLibraryAgents`
|
||||
- Regenerate with: `pnpm generate:api`
|
||||
- **Never** use deprecated `BackendAPI` or `src/lib/autogpt-server-api/*`
|
||||
|
||||
**Code Conventions:**
|
||||
|
||||
- Use function declarations for components and handlers (not arrow functions)
|
||||
- Only arrow functions for small inline lambdas (map, filter, etc.)
|
||||
- Components: `PascalCase`, Hooks: `camelCase` with `use` prefix
|
||||
- No barrel files or `index.ts` re-exports
|
||||
- Minimal comments (code should be self-documenting)
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Use Tailwind CSS utilities only
|
||||
- Use design system components from `src/components/` (atoms, molecules, organisms)
|
||||
- Never use `src/components/__legacy__/*`
|
||||
- Only use Phosphor Icons (`@phosphor-icons/react`)
|
||||
- Prefer design tokens over hardcoded values
|
||||
|
||||
**Error Handling:**
|
||||
|
||||
- Render errors: Use `<ErrorCard />` component
|
||||
- Mutation errors: Display with toast notifications
|
||||
- Manual exceptions: Use `Sentry.captureException()`
|
||||
- Global error boundaries already configured
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Add/update Storybook stories for UI components (`pnpm storybook`)
|
||||
- Run Playwright E2E tests with `pnpm test`
|
||||
- Verify in Chromatic after PR
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Default to client components ("use client")
|
||||
- Server components only for SEO or extreme TTFB needs
|
||||
- Use React Query for server state (via generated hooks)
|
||||
- Co-locate UI state in components/hooks
|
||||
|
||||
### Security Guidelines
|
||||
|
||||
**Cache Protection Middleware** (`/backend/backend/server/middleware/security.py`):
|
||||
|
||||
- Default: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private`
|
||||
- Uses allow list approach for cacheable paths (static assets, health checks, public pages)
|
||||
- Prevents sensitive data caching in browsers/proxies
|
||||
- Add new cacheable endpoints to `CACHEABLE_PATHS`
|
||||
|
||||
### CI/CD Alignment
|
||||
|
||||
The repository has comprehensive CI workflows that test:
|
||||
|
||||
- **Backend**: Python 3.11-3.13, services (Redis/RabbitMQ/ClamAV), Prisma migrations, Poetry lock validation
|
||||
- **Frontend**: Node.js 21, pnpm, Playwright with Docker Compose stack, API schema validation
|
||||
- **Integration**: Full-stack type checking and E2E testing
|
||||
@@ -229,6 +305,7 @@ Match these patterns when developing locally - the copilot setup environment mir
|
||||
## Collaboration with Other AI Assistants
|
||||
|
||||
This repository is actively developed with assistance from Claude (via CLAUDE.md files). When working on this codebase:
|
||||
|
||||
- Check for existing CLAUDE.md files that provide additional context
|
||||
- Follow established patterns and conventions already in the codebase
|
||||
- Maintain consistency with existing code style and architecture
|
||||
@@ -237,8 +314,9 @@ This repository is actively developed with assistance from Claude (via CLAUDE.md
|
||||
## Trust These Instructions
|
||||
|
||||
These instructions are comprehensive and tested. Only perform additional searches if:
|
||||
|
||||
1. Information here is incomplete for your specific task
|
||||
2. You encounter errors not covered by the workarounds
|
||||
3. You need to understand implementation details not covered above
|
||||
|
||||
For detailed platform development patterns, refer to `autogpt_platform/CLAUDE.md` and `AGENTS.md` in the repository root.
|
||||
For detailed platform development patterns, refer to `autogpt_platform/CLAUDE.md` and `AGENTS.md` in the repository root.
|
||||
|
||||
@@ -63,6 +63,9 @@ poetry run pytest path/to/test.py --snapshot-update
|
||||
# Install dependencies
|
||||
cd frontend && pnpm i
|
||||
|
||||
# Generate API client from OpenAPI spec
|
||||
pnpm generate:api
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
@@ -75,12 +78,23 @@ pnpm storybook
|
||||
# Build production
|
||||
pnpm build
|
||||
|
||||
# Format and lint
|
||||
pnpm format
|
||||
|
||||
# Type checking
|
||||
pnpm types
|
||||
```
|
||||
|
||||
We have a components library in autogpt_platform/frontend/src/components/atoms that should be used when adding new pages and components.
|
||||
**📖 Complete Guide**: See `/frontend/CONTRIBUTING.md` and `/frontend/.cursorrules` for comprehensive frontend patterns.
|
||||
|
||||
**Key Frontend Conventions:**
|
||||
|
||||
- Separate render logic from data/behavior in components
|
||||
- Use generated API hooks from `@/app/api/__generated__/endpoints/`
|
||||
- Use function declarations (not arrow functions) for components/handlers
|
||||
- Use design system components from `src/components/` (atoms, molecules, organisms)
|
||||
- Only use Phosphor Icons
|
||||
- Never use `src/components/__legacy__/*` or deprecated `BackendAPI`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -95,11 +109,16 @@ We have a components library in autogpt_platform/frontend/src/components/atoms t
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
- **Framework**: Next.js App Router with React Server Components
|
||||
- **State Management**: React hooks + Supabase client for real-time updates
|
||||
- **Framework**: Next.js 15 App Router (client-first approach)
|
||||
- **Data Fetching**: Type-safe generated API hooks via Orval + React Query
|
||||
- **State Management**: React Query for server state, co-located UI state in components/hooks
|
||||
- **Component Structure**: Separate render logic (`.tsx`) from business logic (`use*.ts` hooks)
|
||||
- **Workflow Builder**: Visual graph editor using @xyflow/react
|
||||
- **UI Components**: Radix UI primitives with Tailwind CSS styling
|
||||
- **UI Components**: shadcn/ui (Radix UI primitives) with Tailwind CSS styling
|
||||
- **Icons**: Phosphor Icons only
|
||||
- **Feature Flags**: LaunchDarkly integration
|
||||
- **Error Handling**: ErrorCard for render errors, toast for mutations, Sentry for exceptions
|
||||
- **Testing**: Playwright for E2E, Storybook for component development
|
||||
|
||||
### Key Concepts
|
||||
|
||||
@@ -153,6 +172,7 @@ Key models (defined in `/backend/schema.prisma`):
|
||||
**Adding a new block:**
|
||||
|
||||
Follow the comprehensive [Block SDK Guide](../../../docs/content/platform/block-sdk-guide.md) which covers:
|
||||
|
||||
- Provider configuration with `ProviderBuilder`
|
||||
- Block schema definition
|
||||
- Authentication (API keys, OAuth, webhooks)
|
||||
@@ -160,6 +180,7 @@ Follow the comprehensive [Block SDK Guide](../../../docs/content/platform/block-
|
||||
- File organization
|
||||
|
||||
Quick steps:
|
||||
|
||||
1. Create new file in `/backend/backend/blocks/`
|
||||
2. Configure provider using `ProviderBuilder` in `_config.py`
|
||||
3. Inherit from `Block` base class
|
||||
@@ -180,10 +201,20 @@ ex: do the inputs and outputs tie well together?
|
||||
|
||||
**Frontend feature development:**
|
||||
|
||||
1. Components go in `/frontend/src/components/`
|
||||
2. Use existing UI components from `/frontend/src/components/ui/`
|
||||
3. Add Storybook stories for new components
|
||||
4. Test with Playwright if user-facing
|
||||
See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
|
||||
|
||||
1. **Pages**: Create in `src/app/(platform)/feature-name/page.tsx`
|
||||
- Add `usePageName.ts` hook for logic
|
||||
- Put sub-components in local `components/` folder
|
||||
2. **Components**: Structure as `ComponentName/ComponentName.tsx` + `useComponentName.ts` + `helpers.ts`
|
||||
- Use design system components from `src/components/` (atoms, molecules, organisms)
|
||||
- Never use `src/components/__legacy__/*`
|
||||
3. **Data fetching**: Use generated API hooks from `@/app/api/__generated__/endpoints/`
|
||||
- Regenerate with `pnpm generate:api`
|
||||
- Pattern: `use{Method}{Version}{OperationName}`
|
||||
4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only
|
||||
5. **Testing**: Add Storybook stories for new components, Playwright for E2E
|
||||
6. **Code conventions**: Function declarations (not arrow functions) for components/handlers
|
||||
|
||||
### Security Implementation
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ start-core:
|
||||
stop-core:
|
||||
docker compose stop deps
|
||||
|
||||
reset-db:
|
||||
rm -rf db/docker/volumes/db/data
|
||||
cd backend && poetry run prisma migrate deploy
|
||||
cd backend && poetry run prisma generate
|
||||
|
||||
# View logs for core services
|
||||
logs-core:
|
||||
docker compose logs -f deps
|
||||
@@ -35,13 +40,18 @@ run-backend:
|
||||
run-frontend:
|
||||
cd frontend && pnpm dev
|
||||
|
||||
test-data:
|
||||
cd backend && poetry run python test/test_data_creator.py
|
||||
|
||||
help:
|
||||
@echo "Usage: make <target>"
|
||||
@echo "Targets:"
|
||||
@echo " start-core - Start just the core services (Supabase, Redis, RabbitMQ) in background"
|
||||
@echo " stop-core - Stop the core services"
|
||||
@echo " reset-db - Reset the database by deleting the volume"
|
||||
@echo " logs-core - Tail the logs for core services"
|
||||
@echo " format - Format & lint backend (Python) and frontend (TypeScript) code"
|
||||
@echo " migrate - Run backend database migrations"
|
||||
@echo " run-backend - Run the backend FastAPI server"
|
||||
@echo " run-frontend - Run the frontend Next.js development server"
|
||||
@echo " run-frontend - Run the frontend Next.js development server"
|
||||
@echo " test-data - Run the test data creator"
|
||||
@@ -94,42 +94,36 @@ def configure_logging(force_cloud_logging: bool = False) -> None:
|
||||
config = LoggingConfig()
|
||||
log_handlers: list[logging.Handler] = []
|
||||
|
||||
structured_logging = config.enable_cloud_logging or force_cloud_logging
|
||||
|
||||
# Console output handlers
|
||||
stdout = logging.StreamHandler(stream=sys.stdout)
|
||||
stdout.setLevel(config.level)
|
||||
stdout.addFilter(BelowLevelFilter(logging.WARNING))
|
||||
if config.level == logging.DEBUG:
|
||||
stdout.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT))
|
||||
else:
|
||||
stdout.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT))
|
||||
if not structured_logging:
|
||||
stdout = logging.StreamHandler(stream=sys.stdout)
|
||||
stdout.setLevel(config.level)
|
||||
stdout.addFilter(BelowLevelFilter(logging.WARNING))
|
||||
if config.level == logging.DEBUG:
|
||||
stdout.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT))
|
||||
else:
|
||||
stdout.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT))
|
||||
|
||||
stderr = logging.StreamHandler()
|
||||
stderr.setLevel(logging.WARNING)
|
||||
if config.level == logging.DEBUG:
|
||||
stderr.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT))
|
||||
else:
|
||||
stderr.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT))
|
||||
stderr = logging.StreamHandler()
|
||||
stderr.setLevel(logging.WARNING)
|
||||
if config.level == logging.DEBUG:
|
||||
stderr.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT))
|
||||
else:
|
||||
stderr.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT))
|
||||
|
||||
log_handlers += [stdout, stderr]
|
||||
log_handlers += [stdout, stderr]
|
||||
|
||||
# Cloud logging setup
|
||||
if config.enable_cloud_logging or force_cloud_logging:
|
||||
import google.cloud.logging
|
||||
from google.cloud.logging.handlers import CloudLoggingHandler
|
||||
from google.cloud.logging_v2.handlers.transports import (
|
||||
BackgroundThreadTransport,
|
||||
)
|
||||
else:
|
||||
# Use Google Cloud Structured Log Handler. Log entries are printed to stdout
|
||||
# in a JSON format which is automatically picked up by Google Cloud Logging.
|
||||
from google.cloud.logging.handlers import StructuredLogHandler
|
||||
|
||||
client = google.cloud.logging.Client()
|
||||
# Use BackgroundThreadTransport to prevent blocking the main thread
|
||||
# and deadlocks when gRPC calls to Google Cloud Logging hang
|
||||
cloud_handler = CloudLoggingHandler(
|
||||
client,
|
||||
name="autogpt_logs",
|
||||
transport=BackgroundThreadTransport,
|
||||
)
|
||||
cloud_handler.setLevel(config.level)
|
||||
log_handlers.append(cloud_handler)
|
||||
structured_log_handler = StructuredLogHandler(stream=sys.stdout)
|
||||
structured_log_handler.setLevel(config.level)
|
||||
log_handlers.append(structured_log_handler)
|
||||
|
||||
# File logging setup
|
||||
if config.enable_file_logging:
|
||||
@@ -185,7 +179,13 @@ def configure_logging(force_cloud_logging: bool = False) -> None:
|
||||
|
||||
# Configure the root logger
|
||||
logging.basicConfig(
|
||||
format=DEBUG_LOG_FORMAT if config.level == logging.DEBUG else SIMPLE_LOG_FORMAT,
|
||||
format=(
|
||||
"%(levelname)s %(message)s"
|
||||
if structured_logging
|
||||
else (
|
||||
DEBUG_LOG_FORMAT if config.level == logging.DEBUG else SIMPLE_LOG_FORMAT
|
||||
)
|
||||
),
|
||||
level=config.level,
|
||||
handlers=log_handlers,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ RUN poetry install --no-ansi --no-root
|
||||
|
||||
# Generate Prisma client
|
||||
COPY autogpt_platform/backend/schema.prisma ./
|
||||
COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/partial_types.py
|
||||
RUN poetry run prisma generate
|
||||
|
||||
FROM debian:13-slim AS server_dependencies
|
||||
@@ -92,6 +93,7 @@ FROM server_dependencies AS migrate
|
||||
|
||||
# Migration stage only needs schema and migrations - much lighter than full backend
|
||||
COPY autogpt_platform/backend/schema.prisma /app/autogpt_platform/backend/
|
||||
COPY autogpt_platform/backend/backend/data/partial_types.py /app/autogpt_platform/backend/backend/data/partial_types.py
|
||||
COPY autogpt_platform/backend/migrations /app/autogpt_platform/backend/migrations
|
||||
|
||||
FROM server_dependencies AS server
|
||||
|
||||
@@ -4,13 +4,13 @@ import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
from pydantic import SecretStr
|
||||
|
||||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
||||
from backend.data.model import APIKeyCredentials, SchemaField
|
||||
from backend.util.file import store_media_file
|
||||
from backend.util.request import Requests
|
||||
from backend.util.type import MediaFileType
|
||||
|
||||
from ._auth import (
|
||||
@@ -114,10 +114,9 @@ class ReadDiscordMessagesBlock(Block):
|
||||
if message.attachments:
|
||||
attachment = message.attachments[0] # Process the first attachment
|
||||
if attachment.filename.endswith((".txt", ".py")):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(attachment.url) as response:
|
||||
file_content = response.text()
|
||||
self.output_data += f"\n\nFile from user: {attachment.filename}\nContent: {file_content}"
|
||||
response = await Requests().get(attachment.url)
|
||||
file_content = response.text()
|
||||
self.output_data += f"\n\nFile from user: {attachment.filename}\nContent: {file_content}"
|
||||
|
||||
await client.close()
|
||||
|
||||
@@ -699,16 +698,15 @@ class SendDiscordFileBlock(Block):
|
||||
|
||||
elif file.startswith(("http://", "https://")):
|
||||
# URL - download the file
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(file) as response:
|
||||
file_bytes = await response.read()
|
||||
response = await Requests().get(file)
|
||||
file_bytes = response.content
|
||||
|
||||
# Try to get filename from URL if not provided
|
||||
if not filename:
|
||||
from urllib.parse import urlparse
|
||||
# Try to get filename from URL if not provided
|
||||
if not filename:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
path = urlparse(file).path
|
||||
detected_filename = Path(path).name or "download"
|
||||
path = urlparse(file).path
|
||||
detected_filename = Path(path).name or "download"
|
||||
else:
|
||||
# Local file path - read from stored media file
|
||||
# This would be a path from a previous block's output
|
||||
|
||||
@@ -62,10 +62,10 @@ TEST_CREDENTIALS_OAUTH = OAuth2Credentials(
|
||||
title="Mock Linear API key",
|
||||
username="mock-linear-username",
|
||||
access_token=SecretStr("mock-linear-access-token"),
|
||||
access_token_expires_at=None,
|
||||
access_token_expires_at=1672531200, # Mock expiration time for short-lived token
|
||||
refresh_token=SecretStr("mock-linear-refresh-token"),
|
||||
refresh_token_expires_at=None,
|
||||
scopes=["mock-linear-scopes"],
|
||||
scopes=["read", "write"],
|
||||
)
|
||||
|
||||
TEST_CREDENTIALS_API_KEY = APIKeyCredentials(
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Linear OAuth handler implementation.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -38,8 +40,9 @@ class LinearOAuthHandler(BaseOAuthHandler):
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.auth_base_url = "https://linear.app/oauth/authorize"
|
||||
self.token_url = "https://api.linear.app/oauth/token" # Correct token URL
|
||||
self.token_url = "https://api.linear.app/oauth/token"
|
||||
self.revoke_url = "https://api.linear.app/oauth/revoke"
|
||||
self.migrate_url = "https://api.linear.app/oauth/migrate_old_token"
|
||||
|
||||
def get_login_url(
|
||||
self, scopes: list[str], state: str, code_challenge: Optional[str]
|
||||
@@ -82,19 +85,84 @@ class LinearOAuthHandler(BaseOAuthHandler):
|
||||
|
||||
return True # Linear doesn't return JSON on successful revoke
|
||||
|
||||
async def migrate_old_token(
|
||||
self, credentials: OAuth2Credentials
|
||||
) -> OAuth2Credentials:
|
||||
"""
|
||||
Migrate an old long-lived token to a new short-lived token with refresh token.
|
||||
|
||||
This uses Linear's /oauth/migrate_old_token endpoint to exchange current
|
||||
long-lived tokens for short-lived tokens with refresh tokens without
|
||||
requiring users to re-authorize.
|
||||
"""
|
||||
if not credentials.access_token:
|
||||
raise ValueError("No access token to migrate")
|
||||
|
||||
request_body = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {credentials.access_token.get_secret_value()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
response = await Requests().post(
|
||||
self.migrate_url, data=request_body, headers=headers
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get("error", "Unknown error")
|
||||
error_description = error_data.get("error_description", "")
|
||||
if error_description:
|
||||
error_message = f"{error_message}: {error_description}"
|
||||
except json.JSONDecodeError:
|
||||
error_message = response.text
|
||||
raise LinearAPIException(
|
||||
f"Failed to migrate Linear token ({response.status}): {error_message}",
|
||||
response.status,
|
||||
)
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
# Extract token expiration
|
||||
now = int(time.time())
|
||||
expires_in = token_data.get("expires_in")
|
||||
access_token_expires_at = None
|
||||
if expires_in:
|
||||
access_token_expires_at = now + expires_in
|
||||
|
||||
new_credentials = OAuth2Credentials(
|
||||
provider=self.PROVIDER_NAME,
|
||||
title=credentials.title,
|
||||
username=credentials.username,
|
||||
access_token=token_data["access_token"],
|
||||
scopes=credentials.scopes, # Preserve original scopes
|
||||
refresh_token=token_data.get("refresh_token"),
|
||||
access_token_expires_at=access_token_expires_at,
|
||||
refresh_token_expires_at=None,
|
||||
)
|
||||
|
||||
new_credentials.id = credentials.id
|
||||
return new_credentials
|
||||
|
||||
async def _refresh_tokens(
|
||||
self, credentials: OAuth2Credentials
|
||||
) -> OAuth2Credentials:
|
||||
if not credentials.refresh_token:
|
||||
raise ValueError(
|
||||
"No refresh token available."
|
||||
) # Linear uses non-expiring tokens
|
||||
"No refresh token available. Token may need to be migrated to the new refresh token system."
|
||||
)
|
||||
|
||||
return await self._request_tokens(
|
||||
{
|
||||
"refresh_token": credentials.refresh_token.get_secret_value(),
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
},
|
||||
current_credentials=credentials,
|
||||
)
|
||||
|
||||
async def _request_tokens(
|
||||
@@ -102,16 +170,33 @@ class LinearOAuthHandler(BaseOAuthHandler):
|
||||
params: dict[str, str],
|
||||
current_credentials: Optional[OAuth2Credentials] = None,
|
||||
) -> OAuth2Credentials:
|
||||
# Determine if this is a refresh token request
|
||||
is_refresh = params.get("grant_type") == "refresh_token"
|
||||
|
||||
# Build request body with appropriate grant_type
|
||||
request_body = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "authorization_code", # Ensure grant_type is correct
|
||||
**params,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
} # Correct header for token request
|
||||
# Set default grant_type if not provided
|
||||
if "grant_type" not in request_body:
|
||||
request_body["grant_type"] = "authorization_code"
|
||||
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
|
||||
# For refresh token requests, support HTTP Basic Authentication as recommended
|
||||
if is_refresh:
|
||||
# Option 1: Use HTTP Basic Auth (preferred by Linear)
|
||||
client_credentials = f"{self.client_id}:{self.client_secret}"
|
||||
encoded_credentials = base64.b64encode(client_credentials.encode()).decode()
|
||||
headers["Authorization"] = f"Basic {encoded_credentials}"
|
||||
|
||||
# Remove client credentials from body when using Basic Auth
|
||||
request_body.pop("client_id", None)
|
||||
request_body.pop("client_secret", None)
|
||||
|
||||
response = await Requests().post(
|
||||
self.token_url, data=request_body, headers=headers
|
||||
)
|
||||
@@ -120,6 +205,9 @@ class LinearOAuthHandler(BaseOAuthHandler):
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get("error", "Unknown error")
|
||||
error_description = error_data.get("error_description", "")
|
||||
if error_description:
|
||||
error_message = f"{error_message}: {error_description}"
|
||||
except json.JSONDecodeError:
|
||||
error_message = response.text
|
||||
raise LinearAPIException(
|
||||
@@ -129,27 +217,84 @@ class LinearOAuthHandler(BaseOAuthHandler):
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
# Note: Linear access tokens do not expire, so we set expires_at to None
|
||||
# Extract token expiration if provided (for new refresh token implementation)
|
||||
now = int(time.time())
|
||||
expires_in = token_data.get("expires_in")
|
||||
access_token_expires_at = None
|
||||
if expires_in:
|
||||
access_token_expires_at = now + expires_in
|
||||
|
||||
# Get username - preserve from current credentials if refreshing
|
||||
username = None
|
||||
if current_credentials and is_refresh:
|
||||
username = current_credentials.username
|
||||
elif "user" in token_data:
|
||||
username = token_data["user"].get("name", "Unknown User")
|
||||
else:
|
||||
# Fetch username using the access token
|
||||
username = await self._request_username(token_data["access_token"])
|
||||
|
||||
new_credentials = OAuth2Credentials(
|
||||
provider=self.PROVIDER_NAME,
|
||||
title=current_credentials.title if current_credentials else None,
|
||||
username=token_data.get("user", {}).get(
|
||||
"name", "Unknown User"
|
||||
), # extract name or set appropriate
|
||||
username=username or "Unknown User",
|
||||
access_token=token_data["access_token"],
|
||||
scopes=token_data["scope"].split(
|
||||
","
|
||||
), # Linear returns comma-separated scopes
|
||||
refresh_token=token_data.get(
|
||||
"refresh_token"
|
||||
), # Linear uses non-expiring tokens so this might be null
|
||||
access_token_expires_at=None,
|
||||
refresh_token_expires_at=None,
|
||||
scopes=(
|
||||
token_data["scope"].split(",")
|
||||
if "scope" in token_data
|
||||
else (current_credentials.scopes if current_credentials else [])
|
||||
),
|
||||
refresh_token=token_data.get("refresh_token"),
|
||||
access_token_expires_at=access_token_expires_at,
|
||||
refresh_token_expires_at=None, # Linear doesn't provide refresh token expiration
|
||||
)
|
||||
|
||||
if current_credentials:
|
||||
new_credentials.id = current_credentials.id
|
||||
|
||||
return new_credentials
|
||||
|
||||
async def get_access_token(self, credentials: OAuth2Credentials) -> str:
|
||||
"""
|
||||
Returns a valid access token, handling migration and refresh as needed.
|
||||
|
||||
This overrides the base implementation to handle Linear's token migration
|
||||
from old long-lived tokens to new short-lived tokens with refresh tokens.
|
||||
"""
|
||||
# If token has no expiration and no refresh token, it might be an old token
|
||||
# that needs migration
|
||||
if (
|
||||
credentials.access_token_expires_at is None
|
||||
and credentials.refresh_token is None
|
||||
):
|
||||
try:
|
||||
# Attempt to migrate the old token
|
||||
migrated_credentials = await self.migrate_old_token(credentials)
|
||||
# Update the credentials store would need to be handled by the caller
|
||||
# For now, use the migrated credentials for this request
|
||||
credentials = migrated_credentials
|
||||
except LinearAPIException:
|
||||
# Migration failed, try to use the old token as-is
|
||||
# This maintains backward compatibility
|
||||
pass
|
||||
|
||||
# Use the standard refresh logic from the base class
|
||||
if self.needs_refresh(credentials):
|
||||
credentials = await self.refresh_tokens(credentials)
|
||||
|
||||
return credentials.access_token.get_secret_value()
|
||||
|
||||
def needs_migration(self, credentials: OAuth2Credentials) -> bool:
|
||||
"""
|
||||
Check if credentials represent an old long-lived token that needs migration.
|
||||
|
||||
Old tokens have no expiration time and no refresh token.
|
||||
"""
|
||||
return (
|
||||
credentials.access_token_expires_at is None
|
||||
and credentials.refresh_token is None
|
||||
)
|
||||
|
||||
async def _request_username(self, access_token: str) -> Optional[str]:
|
||||
# Use the LinearClient to fetch user details using GraphQL
|
||||
from ._api import LinearClient
|
||||
|
||||
@@ -104,8 +104,6 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
|
||||
CLAUDE_4_5_HAIKU = "claude-haiku-4-5-20251001"
|
||||
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
|
||||
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest"
|
||||
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest"
|
||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||
# AI/ML API models
|
||||
AIML_API_QWEN2_5_72B = "Qwen/Qwen2.5-72B-Instruct-Turbo"
|
||||
@@ -224,12 +222,6 @@ MODEL_METADATA = {
|
||||
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 64000
|
||||
), # claude-3-7-sonnet-20250219
|
||||
LlmModel.CLAUDE_3_5_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 8192
|
||||
), # claude-3-5-sonnet-20241022
|
||||
LlmModel.CLAUDE_3_5_HAIKU: ModelMetadata(
|
||||
"anthropic", 200000, 8192
|
||||
), # claude-3-5-haiku-20241022
|
||||
LlmModel.CLAUDE_3_HAIKU: ModelMetadata(
|
||||
"anthropic", 200000, 4096
|
||||
), # claude-3-haiku-20240307
|
||||
@@ -1562,7 +1554,9 @@ class AIConversationBlock(AIBlockBase):
|
||||
("prompt", list),
|
||||
],
|
||||
test_mock={
|
||||
"llm_call": lambda *args, **kwargs: "The 2020 World Series was played at Globe Life Field in Arlington, Texas."
|
||||
"llm_call": lambda *args, **kwargs: dict(
|
||||
response="The 2020 World Series was played at Globe Life Field in Arlington, Texas."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1591,7 +1585,7 @@ class AIConversationBlock(AIBlockBase):
|
||||
),
|
||||
credentials=credentials,
|
||||
)
|
||||
yield "response", response
|
||||
yield "response", response["response"]
|
||||
yield "prompt", self.prompt
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -10,6 +8,7 @@ import pydantic
|
||||
|
||||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
||||
from backend.data.model import SchemaField
|
||||
from backend.util.request import Requests
|
||||
|
||||
|
||||
class RSSEntry(pydantic.BaseModel):
|
||||
@@ -103,35 +102,29 @@ class ReadRSSFeedBlock(Block):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_feed(url: str) -> dict[str, Any]:
|
||||
async def parse_feed(url: str) -> dict[str, Any]:
|
||||
# Security fix: Add protection against memory exhaustion attacks
|
||||
MAX_FEED_SIZE = 10 * 1024 * 1024 # 10MB limit for RSS feeds
|
||||
|
||||
# Validate URL
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Invalid URL scheme: {parsed_url.scheme}")
|
||||
|
||||
# Download with size limit
|
||||
# Download feed content with size limit
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
# Check content length if available
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > MAX_FEED_SIZE:
|
||||
raise ValueError(
|
||||
f"Feed too large: {content_length} bytes exceeds {MAX_FEED_SIZE} limit"
|
||||
)
|
||||
response = await Requests(raise_for_status=True).get(url)
|
||||
|
||||
# Read with size limit
|
||||
content = response.read(MAX_FEED_SIZE + 1)
|
||||
if len(content) > MAX_FEED_SIZE:
|
||||
raise ValueError(
|
||||
f"Feed too large: exceeds {MAX_FEED_SIZE} byte limit"
|
||||
)
|
||||
# Check content length if available
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > MAX_FEED_SIZE:
|
||||
raise ValueError(
|
||||
f"Feed too large: {content_length} bytes exceeds {MAX_FEED_SIZE} limit"
|
||||
)
|
||||
|
||||
# Parse with feedparser using the validated content
|
||||
# feedparser has built-in protection against XML attacks
|
||||
return feedparser.parse(content) # type: ignore
|
||||
# Get content with size limit
|
||||
content = response.content
|
||||
if len(content) > MAX_FEED_SIZE:
|
||||
raise ValueError(f"Feed too large: exceeds {MAX_FEED_SIZE} byte limit")
|
||||
|
||||
# Parse with feedparser using the validated content
|
||||
# feedparser has built-in protection against XML attacks
|
||||
return feedparser.parse(content) # type: ignore
|
||||
except Exception as e:
|
||||
# Log error and return empty feed
|
||||
logging.warning(f"Failed to parse RSS feed from {url}: {e}")
|
||||
@@ -145,7 +138,7 @@ class ReadRSSFeedBlock(Block):
|
||||
while keep_going:
|
||||
keep_going = input_data.run_continuously
|
||||
|
||||
feed = self.parse_feed(input_data.rss_url)
|
||||
feed = await self.parse_feed(input_data.rss_url)
|
||||
all_entries = []
|
||||
|
||||
for entry in feed["entries"]:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import warnings
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
|
||||
@@ -26,6 +27,13 @@ from backend.sdk import (
|
||||
SchemaField,
|
||||
)
|
||||
|
||||
# Suppress false positive cleanup warning of litellm (a dependency of stagehand)
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message="coroutine 'close_litellm_async_clients' was never awaited",
|
||||
category=RuntimeWarning,
|
||||
)
|
||||
|
||||
# Store the original method
|
||||
original_register_signal_handlers = stagehand.main.Stagehand._register_signal_handlers
|
||||
|
||||
|
||||
@@ -362,7 +362,7 @@ class TestLLMStatsTracking:
|
||||
assert block.execution_stats.llm_call_count == 1
|
||||
|
||||
# Check output
|
||||
assert outputs["response"] == {"response": "AI response to conversation"}
|
||||
assert outputs["response"] == "AI response to conversation"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_generator_with_retries(self):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from youtube_transcript_api._api import YouTubeTranscriptApi
|
||||
from youtube_transcript_api._errors import NoTranscriptFound
|
||||
from youtube_transcript_api._transcripts import FetchedTranscript
|
||||
from youtube_transcript_api.formatters import TextFormatter
|
||||
|
||||
@@ -64,7 +65,29 @@ class TranscribeYoutubeVideoBlock(Block):
|
||||
|
||||
@staticmethod
|
||||
def get_transcript(video_id: str) -> FetchedTranscript:
|
||||
return YouTubeTranscriptApi().fetch(video_id=video_id)
|
||||
"""
|
||||
Get transcript for a video, preferring English but falling back to any available language.
|
||||
|
||||
:param video_id: The YouTube video ID
|
||||
:return: The fetched transcript
|
||||
:raises: Any exception except NoTranscriptFound for requested languages
|
||||
"""
|
||||
api = YouTubeTranscriptApi()
|
||||
try:
|
||||
# Try to get English transcript first (default behavior)
|
||||
return api.fetch(video_id=video_id)
|
||||
except NoTranscriptFound:
|
||||
# If English is not available, get the first available transcript
|
||||
transcript_list = api.list(video_id)
|
||||
# Try manually created transcripts first, then generated ones
|
||||
available_transcripts = list(
|
||||
transcript_list._manually_created_transcripts.values()
|
||||
) + list(transcript_list._generated_transcripts.values())
|
||||
if available_transcripts:
|
||||
# Fetch the first available transcript
|
||||
return available_transcripts[0].fetch()
|
||||
# If no transcripts at all, re-raise the original error
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def format_transcript(transcript: FetchedTranscript) -> str:
|
||||
|
||||
@@ -45,9 +45,6 @@ class MainApp(AppProcess):
|
||||
|
||||
app.main(silent=True)
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from typing import Type
|
||||
|
||||
from backend.blocks.ai_music_generator import AIMusicGeneratorBlock
|
||||
from backend.blocks.ai_shortform_video_block import AIShortformVideoCreatorBlock
|
||||
from backend.blocks.ai_shortform_video_block import (
|
||||
AIAdMakerVideoCreatorBlock,
|
||||
AIScreenshotToVideoAdBlock,
|
||||
AIShortformVideoCreatorBlock,
|
||||
)
|
||||
from backend.blocks.apollo.organization import SearchOrganizationsBlock
|
||||
from backend.blocks.apollo.people import SearchPeopleBlock
|
||||
from backend.blocks.apollo.person import GetPersonDetailBlock
|
||||
@@ -72,8 +76,6 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.CLAUDE_4_5_HAIKU: 4,
|
||||
LlmModel.CLAUDE_4_5_SONNET: 9,
|
||||
LlmModel.CLAUDE_3_7_SONNET: 5,
|
||||
LlmModel.CLAUDE_3_5_SONNET: 4,
|
||||
LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00
|
||||
LlmModel.CLAUDE_3_HAIKU: 1,
|
||||
LlmModel.AIML_API_QWEN2_5_72B: 1,
|
||||
LlmModel.AIML_API_LLAMA3_1_70B: 1,
|
||||
@@ -323,7 +325,31 @@ BLOCK_COSTS: dict[Type[Block], list[BlockCost]] = {
|
||||
],
|
||||
AIShortformVideoCreatorBlock: [
|
||||
BlockCost(
|
||||
cost_amount=50,
|
||||
cost_amount=307,
|
||||
cost_filter={
|
||||
"credentials": {
|
||||
"id": revid_credentials.id,
|
||||
"provider": revid_credentials.provider,
|
||||
"type": revid_credentials.type,
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
AIAdMakerVideoCreatorBlock: [
|
||||
BlockCost(
|
||||
cost_amount=714,
|
||||
cost_filter={
|
||||
"credentials": {
|
||||
"id": revid_credentials.id,
|
||||
"provider": revid_credentials.provider,
|
||||
"type": revid_credentials.type,
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
AIScreenshotToVideoAdBlock: [
|
||||
BlockCost(
|
||||
cost_amount=612,
|
||||
cost_filter={
|
||||
"credentials": {
|
||||
"id": revid_credentials.id,
|
||||
|
||||
@@ -347,6 +347,9 @@ class APIKeyCredentials(_BaseCredentials):
|
||||
"""Unix timestamp (seconds) indicating when the API key expires (if at all)"""
|
||||
|
||||
def auth_header(self) -> str:
|
||||
# Linear API keys should not have Bearer prefix
|
||||
if self.provider == "linear":
|
||||
return self.api_key.get_secret_value()
|
||||
return f"Bearer {self.api_key.get_secret_value()}"
|
||||
|
||||
|
||||
|
||||
5
autogpt_platform/backend/backend/data/partial_types.py
Normal file
5
autogpt_platform/backend/backend/data/partial_types.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import prisma.models
|
||||
|
||||
|
||||
class StoreAgentWithRank(prisma.models.StoreAgent):
|
||||
rank: float
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Concatenate, ParamSpec, TypeVar, cast
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Callable, Concatenate, ParamSpec, TypeVar, cast
|
||||
|
||||
from backend.data import db
|
||||
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
|
||||
@@ -39,6 +40,7 @@ from backend.data.notifications import (
|
||||
)
|
||||
from backend.data.user import (
|
||||
get_active_user_ids_in_timerange,
|
||||
get_user_by_id,
|
||||
get_user_email_by_id,
|
||||
get_user_email_verification,
|
||||
get_user_integrations,
|
||||
@@ -56,6 +58,9 @@ from backend.util.service import (
|
||||
)
|
||||
from backend.util.settings import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
config = Config()
|
||||
logger = logging.getLogger(__name__)
|
||||
P = ParamSpec("P")
|
||||
@@ -75,15 +80,17 @@ async def _get_credits(user_id: str) -> int:
|
||||
|
||||
|
||||
class DatabaseManager(AppService):
|
||||
def run_service(self) -> None:
|
||||
logger.info(f"[{self.service_name}] ⏳ Connecting to Database...")
|
||||
self.run_and_wait(db.connect())
|
||||
super().run_service()
|
||||
@asynccontextmanager
|
||||
async def lifespan(self, app: "FastAPI"):
|
||||
async with super().lifespan(app):
|
||||
logger.info(f"[{self.service_name}] ⏳ Connecting to Database...")
|
||||
await db.connect()
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
logger.info(f"[{self.service_name}] ⏳ Disconnecting Database...")
|
||||
self.run_and_wait(db.disconnect())
|
||||
logger.info(f"[{self.service_name}] ✅ Ready")
|
||||
yield
|
||||
|
||||
logger.info(f"[{self.service_name}] ⏳ Disconnecting Database...")
|
||||
await db.disconnect()
|
||||
|
||||
async def health_check(self) -> str:
|
||||
if not db.is_connected():
|
||||
@@ -146,6 +153,7 @@ class DatabaseManager(AppService):
|
||||
|
||||
# User Comms - async
|
||||
get_active_user_ids_in_timerange = _(get_active_user_ids_in_timerange)
|
||||
get_user_by_id = _(get_user_by_id)
|
||||
get_user_email_by_id = _(get_user_email_by_id)
|
||||
get_user_email_verification = _(get_user_email_verification)
|
||||
get_user_notification_preference = _(get_user_notification_preference)
|
||||
@@ -231,6 +239,7 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
||||
get_node = d.get_node
|
||||
get_node_execution = d.get_node_execution
|
||||
get_node_executions = d.get_node_executions
|
||||
get_user_by_id = d.get_user_by_id
|
||||
get_user_integrations = d.get_user_integrations
|
||||
upsert_execution_input = d.upsert_execution_input
|
||||
upsert_execution_output = d.upsert_execution_output
|
||||
|
||||
@@ -1714,6 +1714,8 @@ class ExecutionManager(AppProcess):
|
||||
|
||||
logger.info(f"{prefix} ✅ Finished GraphExec cleanup")
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
# ------- UTILITIES ------- #
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ class Scheduler(AppService):
|
||||
raise UnhealthyServiceError("Scheduler is still initializing")
|
||||
|
||||
# Check if we're in the middle of cleanup
|
||||
if self.cleaned_up:
|
||||
if self._shutting_down:
|
||||
return await super().health_check()
|
||||
|
||||
# Normal operation - check if scheduler is running
|
||||
@@ -375,7 +375,6 @@ class Scheduler(AppService):
|
||||
super().run_service()
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
if self.scheduler:
|
||||
logger.info("⏳ Shutting down scheduler...")
|
||||
self.scheduler.shutdown(wait=True)
|
||||
@@ -390,7 +389,7 @@ class Scheduler(AppService):
|
||||
logger.info("⏳ Waiting for event loop thread to finish...")
|
||||
_event_loop_thread.join(timeout=SCHEDULER_OPERATION_TIMEOUT_SECONDS)
|
||||
|
||||
logger.info("Scheduler cleanup complete.")
|
||||
super().cleanup()
|
||||
|
||||
@expose
|
||||
def add_graph_execution_schedule(
|
||||
|
||||
@@ -34,6 +34,7 @@ from backend.data.graph import GraphModel, Node
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.util.cache import cached
|
||||
from backend.util.clients import (
|
||||
get_async_execution_event_bus,
|
||||
get_async_execution_queue,
|
||||
@@ -41,11 +42,12 @@ from backend.util.clients import (
|
||||
get_integration_credentials_store,
|
||||
)
|
||||
from backend.util.exceptions import GraphValidationError, NotFoundError
|
||||
from backend.util.logging import TruncatedLogger
|
||||
from backend.util.logging import TruncatedLogger, is_structured_logging_enabled
|
||||
from backend.util.settings import Config
|
||||
from backend.util.type import convert
|
||||
|
||||
|
||||
@cached(maxsize=1000, ttl_seconds=3600)
|
||||
async def get_user_context(user_id: str) -> UserContext:
|
||||
"""
|
||||
Get UserContext for a user, always returns a valid context with timezone.
|
||||
@@ -53,7 +55,11 @@ async def get_user_context(user_id: str) -> UserContext:
|
||||
"""
|
||||
user_context = UserContext(timezone="UTC") # Default to UTC
|
||||
try:
|
||||
user = await get_user_by_id(user_id)
|
||||
if prisma.is_connected():
|
||||
user = await get_user_by_id(user_id)
|
||||
else:
|
||||
user = await get_database_manager_async_client().get_user_by_id(user_id)
|
||||
|
||||
if user and user.timezone and user.timezone != "not-set":
|
||||
user_context.timezone = user.timezone
|
||||
logger.debug(f"Retrieved user context: timezone={user.timezone}")
|
||||
@@ -93,7 +99,11 @@ class LogMetadata(TruncatedLogger):
|
||||
"node_id": node_id,
|
||||
"block_name": block_name,
|
||||
}
|
||||
prefix = f"[ExecutionManager|uid:{user_id}|gid:{graph_id}|nid:{node_id}]|geid:{graph_eid}|neid:{node_eid}|{block_name}]"
|
||||
prefix = (
|
||||
"[ExecutionManager]"
|
||||
if is_structured_logging_enabled()
|
||||
else f"[ExecutionManager|uid:{user_id}|gid:{graph_id}|nid:{node_id}]|geid:{graph_eid}|neid:{node_eid}|{block_name}]" # noqa
|
||||
)
|
||||
super().__init__(
|
||||
logger,
|
||||
max_length=max_length,
|
||||
|
||||
@@ -1017,10 +1017,14 @@ class NotificationManager(AppService):
|
||||
logger.exception(f"Fatal error in consumer for {queue_name}: {e}")
|
||||
raise
|
||||
|
||||
@continuous_retry()
|
||||
def run_service(self):
|
||||
self.run_and_wait(self._run_service())
|
||||
# Queue the main _run_service task
|
||||
asyncio.run_coroutine_threadsafe(self._run_service(), self.shared_event_loop)
|
||||
|
||||
# Start the main event loop
|
||||
super().run_service()
|
||||
|
||||
@continuous_retry()
|
||||
async def _run_service(self):
|
||||
logger.info(f"[{self.service_name}] ⏳ Configuring RabbitMQ...")
|
||||
self.rabbitmq_service = rabbitmq.AsyncRabbitMQ(self.rabbitmq_config)
|
||||
@@ -1086,10 +1090,11 @@ class NotificationManager(AppService):
|
||||
def cleanup(self):
|
||||
"""Cleanup service resources"""
|
||||
self.running = False
|
||||
super().cleanup()
|
||||
logger.info(f"[{self.service_name}] ⏳ Disconnecting RabbitMQ...")
|
||||
logger.info("⏳ Disconnecting RabbitMQ...")
|
||||
self.run_and_wait(self.rabbitmq_service.disconnect())
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class NotificationManagerClient(AppServiceClient):
|
||||
@classmethod
|
||||
|
||||
@@ -321,10 +321,6 @@ class AgentServer(backend.util.service.AppProcess):
|
||||
|
||||
uvicorn.run(**uvicorn_config)
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
logger.info(f"[{self.service_name}] ⏳ Shutting down Agent Server...")
|
||||
|
||||
@staticmethod
|
||||
async def test_execute_graph(
|
||||
graph_id: str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import fastapi
|
||||
@@ -71,64 +72,199 @@ async def get_store_agents(
|
||||
logger.debug(
|
||||
f"Getting store agents. featured={featured}, creators={creators}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
|
||||
)
|
||||
search_term = sanitize_query(search_query)
|
||||
where_clause: prisma.types.StoreAgentWhereInput = {"is_available": True}
|
||||
if featured:
|
||||
where_clause["featured"] = featured
|
||||
|
||||
sanitized_creators = []
|
||||
if creators:
|
||||
where_clause["creator_username"] = {"in": creators}
|
||||
for c in creators:
|
||||
sanitized_creators.append(sanitize_query(c))
|
||||
|
||||
sanitized_category = None
|
||||
if category:
|
||||
where_clause["categories"] = {"has": category}
|
||||
|
||||
if search_term:
|
||||
where_clause["OR"] = [
|
||||
{"agent_name": {"contains": search_term, "mode": "insensitive"}},
|
||||
{"description": {"contains": search_term, "mode": "insensitive"}},
|
||||
]
|
||||
|
||||
order_by = []
|
||||
if sorted_by == "rating":
|
||||
order_by.append({"rating": "desc"})
|
||||
elif sorted_by == "runs":
|
||||
order_by.append({"runs": "desc"})
|
||||
elif sorted_by == "name":
|
||||
order_by.append({"agent_name": "asc"})
|
||||
sanitized_category = sanitize_query(category)
|
||||
|
||||
try:
|
||||
agents = await prisma.models.StoreAgent.prisma().find_many(
|
||||
where=where_clause,
|
||||
order=order_by,
|
||||
skip=(page - 1) * page_size,
|
||||
take=page_size,
|
||||
)
|
||||
|
||||
total = await prisma.models.StoreAgent.prisma().count(where=where_clause)
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
store_agents: list[backend.server.v2.store.model.StoreAgent] = []
|
||||
for agent in agents:
|
||||
try:
|
||||
# Create the StoreAgent object safely
|
||||
store_agent = backend.server.v2.store.model.StoreAgent(
|
||||
slug=agent.slug,
|
||||
agent_name=agent.agent_name,
|
||||
agent_image=agent.agent_image[0] if agent.agent_image else "",
|
||||
creator=agent.creator_username or "Needs Profile",
|
||||
creator_avatar=agent.creator_avatar or "",
|
||||
sub_heading=agent.sub_heading,
|
||||
description=agent.description,
|
||||
runs=agent.runs,
|
||||
rating=agent.rating,
|
||||
# If search_query is provided, use full-text search
|
||||
if search_query:
|
||||
search_term = sanitize_query(search_query)
|
||||
if not search_term:
|
||||
# Return empty results for invalid search query
|
||||
return backend.server.v2.store.model.StoreAgentsResponse(
|
||||
agents=[],
|
||||
pagination=backend.server.v2.store.model.Pagination(
|
||||
current_page=page,
|
||||
total_items=0,
|
||||
total_pages=0,
|
||||
page_size=page_size,
|
||||
),
|
||||
)
|
||||
# Add to the list only if creation was successful
|
||||
store_agents.append(store_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
# You could log the error here if needed
|
||||
logger.error(
|
||||
f"Error parsing Store agent when getting store agents from db: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Whitelist allowed order_by columns
|
||||
ALLOWED_ORDER_BY = {
|
||||
"rating": "rating DESC, rank DESC",
|
||||
"runs": "runs DESC, rank DESC",
|
||||
"name": "agent_name ASC, rank DESC",
|
||||
"updated_at": "updated_at DESC, rank DESC",
|
||||
}
|
||||
|
||||
# Validate and get order clause
|
||||
if sorted_by and sorted_by in ALLOWED_ORDER_BY:
|
||||
order_by_clause = ALLOWED_ORDER_BY[sorted_by]
|
||||
else:
|
||||
order_by_clause = "updated_at DESC, rank DESC",
|
||||
|
||||
# Build WHERE conditions and parameters list
|
||||
where_parts: list[str] = []
|
||||
params: list[typing.Any] = [search_term] # $1 - search term
|
||||
param_index = 2 # Start at $2 for next parameter
|
||||
|
||||
# Always filter for available agents
|
||||
where_parts.append("is_available = true")
|
||||
|
||||
if featured:
|
||||
where_parts.append("featured = true")
|
||||
|
||||
if creators and sanitized_creators:
|
||||
# Use ANY with array parameter
|
||||
where_parts.append(f"creator_username = ANY(${param_index})")
|
||||
params.append(sanitized_creators)
|
||||
param_index += 1
|
||||
|
||||
if category and sanitized_category:
|
||||
where_parts.append(f"${param_index} = ANY(categories)")
|
||||
params.append(sanitized_category)
|
||||
param_index += 1
|
||||
|
||||
sql_where_clause: str = " AND ".join(where_parts) if where_parts else "1=1"
|
||||
|
||||
# Add pagination params
|
||||
params.extend([page_size, offset])
|
||||
limit_param = f"${param_index}"
|
||||
offset_param = f"${param_index + 1}"
|
||||
|
||||
# Execute full-text search query with parameterized values
|
||||
sql_query = f"""
|
||||
SELECT
|
||||
slug,
|
||||
agent_name,
|
||||
agent_image,
|
||||
creator_username,
|
||||
creator_avatar,
|
||||
sub_heading,
|
||||
description,
|
||||
runs,
|
||||
rating,
|
||||
categories,
|
||||
featured,
|
||||
is_available,
|
||||
updated_at,
|
||||
ts_rank_cd(search, query) AS rank
|
||||
FROM "StoreAgent",
|
||||
plainto_tsquery('english', $1) AS query
|
||||
WHERE {sql_where_clause}
|
||||
AND search @@ query
|
||||
ORDER BY {order_by_clause}
|
||||
LIMIT {limit_param} OFFSET {offset_param}
|
||||
"""
|
||||
|
||||
# Count query for pagination - only uses search term parameter
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM "StoreAgent",
|
||||
plainto_tsquery('english', $1) AS query
|
||||
WHERE {sql_where_clause}
|
||||
AND search @@ query
|
||||
"""
|
||||
|
||||
# Execute both queries with parameters
|
||||
agents = await prisma.client.get_client().query_raw(
|
||||
typing.cast(typing.LiteralString, sql_query), *params
|
||||
)
|
||||
|
||||
# For count, use params without pagination (last 2 params)
|
||||
count_params = params[:-2]
|
||||
count_result = await prisma.client.get_client().query_raw(
|
||||
typing.cast(typing.LiteralString, count_query), *count_params
|
||||
)
|
||||
|
||||
total = count_result[0]["count"] if count_result else 0
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
# Convert raw results to StoreAgent models
|
||||
store_agents: list[backend.server.v2.store.model.StoreAgent] = []
|
||||
for agent in agents:
|
||||
try:
|
||||
store_agent = backend.server.v2.store.model.StoreAgent(
|
||||
slug=agent["slug"],
|
||||
agent_name=agent["agent_name"],
|
||||
agent_image=(
|
||||
agent["agent_image"][0] if agent["agent_image"] else ""
|
||||
),
|
||||
creator=agent["creator_username"] or "Needs Profile",
|
||||
creator_avatar=agent["creator_avatar"] or "",
|
||||
sub_heading=agent["sub_heading"],
|
||||
description=agent["description"],
|
||||
runs=agent["runs"],
|
||||
rating=agent["rating"],
|
||||
)
|
||||
store_agents.append(store_agent)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Store agent from search results: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
# Non-search query path (original logic)
|
||||
where_clause: prisma.types.StoreAgentWhereInput = {"is_available": True}
|
||||
if featured:
|
||||
where_clause["featured"] = featured
|
||||
if creators:
|
||||
where_clause["creator_username"] = {"in": sanitized_creators}
|
||||
if sanitized_category:
|
||||
where_clause["categories"] = {"has": sanitized_category}
|
||||
|
||||
order_by = []
|
||||
if sorted_by == "rating":
|
||||
order_by.append({"rating": "desc"})
|
||||
elif sorted_by == "runs":
|
||||
order_by.append({"runs": "desc"})
|
||||
elif sorted_by == "name":
|
||||
order_by.append({"agent_name": "asc"})
|
||||
|
||||
agents = await prisma.models.StoreAgent.prisma().find_many(
|
||||
where=where_clause,
|
||||
order=order_by,
|
||||
skip=(page - 1) * page_size,
|
||||
take=page_size,
|
||||
)
|
||||
|
||||
total = await prisma.models.StoreAgent.prisma().count(where=where_clause)
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
store_agents: list[backend.server.v2.store.model.StoreAgent] = []
|
||||
for agent in agents:
|
||||
try:
|
||||
# Create the StoreAgent object safely
|
||||
store_agent = backend.server.v2.store.model.StoreAgent(
|
||||
slug=agent.slug,
|
||||
agent_name=agent.agent_name,
|
||||
agent_image=agent.agent_image[0] if agent.agent_image else "",
|
||||
creator=agent.creator_username or "Needs Profile",
|
||||
creator_avatar=agent.creator_avatar or "",
|
||||
sub_heading=agent.sub_heading,
|
||||
description=agent.description,
|
||||
runs=agent.runs,
|
||||
rating=agent.rating,
|
||||
)
|
||||
# Add to the list only if creation was successful
|
||||
store_agents.append(store_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
# You could log the error here if needed
|
||||
logger.error(
|
||||
f"Error parsing Store agent when getting store agents from db: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(f"Found {len(store_agents)} agents")
|
||||
return backend.server.v2.store.model.StoreAgentsResponse(
|
||||
|
||||
@@ -20,7 +20,7 @@ async def setup_prisma():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agents(mocker):
|
||||
# Mock data
|
||||
mock_agents = [
|
||||
@@ -64,7 +64,7 @@ async def test_get_store_agents(mocker):
|
||||
mock_store_agent.return_value.count.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agent_details(mocker):
|
||||
# Mock data
|
||||
mock_agent = prisma.models.StoreAgent(
|
||||
@@ -173,7 +173,7 @@ async def test_get_store_agent_details(mocker):
|
||||
mock_store_listing_db.return_value.find_first.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_creator_details(mocker):
|
||||
# Mock data
|
||||
mock_creator_data = prisma.models.Creator(
|
||||
@@ -210,7 +210,7 @@ async def test_get_store_creator_details(mocker):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_create_store_submission(mocker):
|
||||
# Mock data
|
||||
mock_agent = prisma.models.AgentGraph(
|
||||
@@ -282,7 +282,7 @@ async def test_create_store_submission(mocker):
|
||||
mock_store_listing.return_value.create.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_update_profile(mocker):
|
||||
# Mock data
|
||||
mock_profile = prisma.models.Profile(
|
||||
@@ -327,7 +327,7 @@ async def test_update_profile(mocker):
|
||||
mock_profile_db.return_value.update.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_user_profile(mocker):
|
||||
# Mock data
|
||||
mock_profile = prisma.models.Profile(
|
||||
@@ -359,3 +359,63 @@ async def test_get_user_profile(mocker):
|
||||
assert result.description == "Test description"
|
||||
assert result.links == ["link1", "link2"]
|
||||
assert result.avatar_url == "avatar.jpg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agents_with_search_parameterized(mocker):
|
||||
"""Test that search query uses parameterized SQL - validates the fix works"""
|
||||
|
||||
# Call function with search query containing potential SQL injection
|
||||
malicious_search = "test'; DROP TABLE StoreAgent; --"
|
||||
result = await db.get_store_agents(search_query=malicious_search)
|
||||
|
||||
# Verify query executed safely
|
||||
assert isinstance(result.agents, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agents_with_search_and_filters_parameterized():
|
||||
"""Test parameterized SQL with multiple filters"""
|
||||
|
||||
# Call with multiple filters including potential injection attempts
|
||||
result = await db.get_store_agents(
|
||||
search_query="test",
|
||||
creators=["creator1'; DROP TABLE Users; --", "creator2"],
|
||||
category="AI'; DELETE FROM StoreAgent; --",
|
||||
featured=True,
|
||||
sorted_by="rating",
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
# Verify the query executed without error
|
||||
assert isinstance(result.agents, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agents_search_with_invalid_sort_by():
|
||||
"""Test that invalid sorted_by value doesn't cause SQL injection""" # Try to inject SQL via sorted_by parameter
|
||||
malicious_sort = "rating; DROP TABLE Users; --"
|
||||
result = await db.get_store_agents(
|
||||
search_query="test",
|
||||
sorted_by=malicious_sort,
|
||||
)
|
||||
|
||||
# Verify the query executed without error
|
||||
# Invalid sort_by should fall back to default, not cause SQL injection
|
||||
assert isinstance(result.agents, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_store_agents_search_category_array_injection():
|
||||
"""Test that category parameter is safely passed as a parameter"""
|
||||
# Try SQL injection via category
|
||||
malicious_category = "AI'; DROP TABLE StoreAgent; --"
|
||||
result = await db.get_store_agents(
|
||||
search_query="test",
|
||||
category=malicious_category,
|
||||
)
|
||||
|
||||
# Verify the query executed without error
|
||||
# Category should be parameterized, preventing SQL injection
|
||||
assert isinstance(result.agents, list)
|
||||
|
||||
@@ -40,23 +40,13 @@ async def get_profile(
|
||||
Get the profile details for the authenticated user.
|
||||
Cached for 1 hour per user.
|
||||
"""
|
||||
try:
|
||||
profile = await backend.server.v2.store.db.get_user_profile(user_id)
|
||||
if profile is None:
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Profile not found"},
|
||||
)
|
||||
return profile
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch user profile for %s: %s", user_id, e)
|
||||
profile = await backend.server.v2.store.db.get_user_profile(user_id)
|
||||
if profile is None:
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Failed to retrieve user profile",
|
||||
"hint": "Check database connection.",
|
||||
},
|
||||
status_code=404,
|
||||
content={"detail": "Profile not found"},
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -83,20 +73,10 @@ async def update_or_create_profile(
|
||||
Raises:
|
||||
HTTPException: If there is an error updating the profile
|
||||
"""
|
||||
try:
|
||||
updated_profile = await backend.server.v2.store.db.update_profile(
|
||||
user_id=user_id, profile=profile
|
||||
)
|
||||
return updated_profile
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update profile for user %s: %s", user_id, e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Failed to update user profile",
|
||||
"hint": "Validate request data.",
|
||||
},
|
||||
)
|
||||
updated_profile = await backend.server.v2.store.db.update_profile(
|
||||
user_id=user_id, profile=profile
|
||||
)
|
||||
return updated_profile
|
||||
|
||||
|
||||
##############################################
|
||||
@@ -155,26 +135,16 @@ async def get_agents(
|
||||
status_code=422, detail="Page size must be greater than 0"
|
||||
)
|
||||
|
||||
try:
|
||||
agents = await store_cache._get_cached_store_agents(
|
||||
featured=featured,
|
||||
creator=creator,
|
||||
sorted_by=sorted_by,
|
||||
search_query=search_query,
|
||||
category=category,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return agents
|
||||
except Exception as e:
|
||||
logger.exception("Failed to retrieve store agents: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Failed to retrieve store agents",
|
||||
"hint": "Check database or search parameters.",
|
||||
},
|
||||
)
|
||||
agents = await store_cache._get_cached_store_agents(
|
||||
featured=featured,
|
||||
creator=creator,
|
||||
sorted_by=sorted_by,
|
||||
search_query=search_query,
|
||||
category=category,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return agents
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -189,22 +159,13 @@ async def get_agent(username: str, agent_name: str):
|
||||
|
||||
It returns the store listing agents details.
|
||||
"""
|
||||
try:
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
# URL decode the agent name since it comes from the URL path
|
||||
agent_name = urllib.parse.unquote(agent_name).lower()
|
||||
agent = await store_cache._get_cached_agent_details(
|
||||
username=username, agent_name=agent_name
|
||||
)
|
||||
return agent
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting store agent details")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "An error occurred while retrieving the store agent details"
|
||||
},
|
||||
)
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
# URL decode the agent name since it comes from the URL path
|
||||
agent_name = urllib.parse.unquote(agent_name).lower()
|
||||
agent = await store_cache._get_cached_agent_details(
|
||||
username=username, agent_name=agent_name
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -217,17 +178,10 @@ async def get_graph_meta_by_store_listing_version_id(store_listing_version_id: s
|
||||
"""
|
||||
Get Agent Graph from Store Listing Version ID.
|
||||
"""
|
||||
try:
|
||||
graph = await backend.server.v2.store.db.get_available_graph(
|
||||
store_listing_version_id
|
||||
)
|
||||
return graph
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting agent graph")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while retrieving the agent graph"},
|
||||
)
|
||||
graph = await backend.server.v2.store.db.get_available_graph(
|
||||
store_listing_version_id
|
||||
)
|
||||
return graph
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -241,18 +195,11 @@ async def get_store_agent(store_listing_version_id: str):
|
||||
"""
|
||||
Get Store Agent Details from Store Listing Version ID.
|
||||
"""
|
||||
try:
|
||||
agent = await backend.server.v2.store.db.get_store_agent_by_version_id(
|
||||
store_listing_version_id
|
||||
)
|
||||
agent = await backend.server.v2.store.db.get_store_agent_by_version_id(
|
||||
store_listing_version_id
|
||||
)
|
||||
|
||||
return agent
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting store agent")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while retrieving the store agent"},
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -280,24 +227,17 @@ async def create_review(
|
||||
Returns:
|
||||
The created review
|
||||
"""
|
||||
try:
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
agent_name = urllib.parse.unquote(agent_name).lower()
|
||||
# Create the review
|
||||
created_review = await backend.server.v2.store.db.create_store_review(
|
||||
user_id=user_id,
|
||||
store_listing_version_id=review.store_listing_version_id,
|
||||
score=review.score,
|
||||
comments=review.comments,
|
||||
)
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
agent_name = urllib.parse.unquote(agent_name).lower()
|
||||
# Create the review
|
||||
created_review = await backend.server.v2.store.db.create_store_review(
|
||||
user_id=user_id,
|
||||
store_listing_version_id=review.store_listing_version_id,
|
||||
score=review.score,
|
||||
comments=review.comments,
|
||||
)
|
||||
|
||||
return created_review
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst creating store review")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while creating the store review"},
|
||||
)
|
||||
return created_review
|
||||
|
||||
|
||||
##############################################
|
||||
@@ -340,21 +280,14 @@ async def get_creators(
|
||||
status_code=422, detail="Page size must be greater than 0"
|
||||
)
|
||||
|
||||
try:
|
||||
creators = await store_cache._get_cached_store_creators(
|
||||
featured=featured,
|
||||
search_query=search_query,
|
||||
sorted_by=sorted_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return creators
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting store creators")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while retrieving the store creators"},
|
||||
)
|
||||
creators = await store_cache._get_cached_store_creators(
|
||||
featured=featured,
|
||||
search_query=search_query,
|
||||
sorted_by=sorted_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return creators
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -370,18 +303,9 @@ async def get_creator(
|
||||
Get the details of a creator.
|
||||
- Creator Details Page
|
||||
"""
|
||||
try:
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
creator = await store_cache._get_cached_creator_details(username=username)
|
||||
return creator
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting creator details")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "An error occurred while retrieving the creator details"
|
||||
},
|
||||
)
|
||||
username = urllib.parse.unquote(username).lower()
|
||||
creator = await store_cache._get_cached_creator_details(username=username)
|
||||
return creator
|
||||
|
||||
|
||||
############################################
|
||||
@@ -404,17 +328,10 @@ async def get_my_agents(
|
||||
"""
|
||||
Get user's own agents.
|
||||
"""
|
||||
try:
|
||||
agents = await backend.server.v2.store.db.get_my_agents(
|
||||
user_id, page=page, page_size=page_size
|
||||
)
|
||||
return agents
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting my agents")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while retrieving the my agents"},
|
||||
)
|
||||
agents = await backend.server.v2.store.db.get_my_agents(
|
||||
user_id, page=page, page_size=page_size
|
||||
)
|
||||
return agents
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -438,19 +355,12 @@ async def delete_submission(
|
||||
Returns:
|
||||
bool: True if the submission was successfully deleted, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = await backend.server.v2.store.db.delete_store_submission(
|
||||
user_id=user_id,
|
||||
submission_id=submission_id,
|
||||
)
|
||||
result = await backend.server.v2.store.db.delete_store_submission(
|
||||
user_id=user_id,
|
||||
submission_id=submission_id,
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst deleting store submission")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while deleting the store submission"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -488,21 +398,12 @@ async def get_submissions(
|
||||
raise fastapi.HTTPException(
|
||||
status_code=422, detail="Page size must be greater than 0"
|
||||
)
|
||||
try:
|
||||
listings = await backend.server.v2.store.db.get_store_submissions(
|
||||
user_id=user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return listings
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst getting store submissions")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "An error occurred while retrieving the store submissions"
|
||||
},
|
||||
)
|
||||
listings = await backend.server.v2.store.db.get_store_submissions(
|
||||
user_id=user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return listings
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -529,36 +430,23 @@ async def create_submission(
|
||||
Raises:
|
||||
HTTPException: If there is an error creating the submission
|
||||
"""
|
||||
try:
|
||||
result = await backend.server.v2.store.db.create_store_submission(
|
||||
user_id=user_id,
|
||||
agent_id=submission_request.agent_id,
|
||||
agent_version=submission_request.agent_version,
|
||||
slug=submission_request.slug,
|
||||
name=submission_request.name,
|
||||
video_url=submission_request.video_url,
|
||||
image_urls=submission_request.image_urls,
|
||||
description=submission_request.description,
|
||||
instructions=submission_request.instructions,
|
||||
sub_heading=submission_request.sub_heading,
|
||||
categories=submission_request.categories,
|
||||
changes_summary=submission_request.changes_summary or "Initial Submission",
|
||||
recommended_schedule_cron=submission_request.recommended_schedule_cron,
|
||||
)
|
||||
result = await backend.server.v2.store.db.create_store_submission(
|
||||
user_id=user_id,
|
||||
agent_id=submission_request.agent_id,
|
||||
agent_version=submission_request.agent_version,
|
||||
slug=submission_request.slug,
|
||||
name=submission_request.name,
|
||||
video_url=submission_request.video_url,
|
||||
image_urls=submission_request.image_urls,
|
||||
description=submission_request.description,
|
||||
instructions=submission_request.instructions,
|
||||
sub_heading=submission_request.sub_heading,
|
||||
categories=submission_request.categories,
|
||||
changes_summary=submission_request.changes_summary or "Initial Submission",
|
||||
recommended_schedule_cron=submission_request.recommended_schedule_cron,
|
||||
)
|
||||
|
||||
return result
|
||||
except backend.server.v2.store.exceptions.SlugAlreadyInUseError as e:
|
||||
logger.warning("Slug already in use: %s", str(e))
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=409,
|
||||
content={"detail": str(e)},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst creating store submission")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while creating the store submission"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -627,36 +515,10 @@ async def upload_submission_media(
|
||||
Raises:
|
||||
HTTPException: If there is an error uploading the media
|
||||
"""
|
||||
try:
|
||||
media_url = await backend.server.v2.store.media.upload_media(
|
||||
user_id=user_id, file=file
|
||||
)
|
||||
return media_url
|
||||
except backend.server.v2.store.exceptions.VirusDetectedError as e:
|
||||
logger.warning(f"Virus detected in uploaded file: {e.threat_name}")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"detail": f"File rejected due to virus detection: {e.threat_name}",
|
||||
"error_type": "virus_detected",
|
||||
"threat_name": e.threat_name,
|
||||
},
|
||||
)
|
||||
except backend.server.v2.store.exceptions.VirusScanError as e:
|
||||
logger.error(f"Virus scanning failed: {str(e)}")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"detail": "Virus scanning service unavailable. Please try again later.",
|
||||
"error_type": "virus_scan_failed",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst uploading submission media")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while uploading the media file"},
|
||||
)
|
||||
media_url = await backend.server.v2.store.media.upload_media(
|
||||
user_id=user_id, file=file
|
||||
)
|
||||
return media_url
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -679,44 +541,35 @@ async def generate_image(
|
||||
Returns:
|
||||
JSONResponse: JSON containing the URL of the generated image
|
||||
"""
|
||||
try:
|
||||
agent = await backend.data.graph.get_graph(agent_id, user_id=user_id)
|
||||
agent = await backend.data.graph.get_graph(agent_id, user_id=user_id)
|
||||
|
||||
if not agent:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=404, detail=f"Agent with ID {agent_id} not found"
|
||||
)
|
||||
# Use .jpeg here since we are generating JPEG images
|
||||
filename = f"agent_{agent_id}.jpeg"
|
||||
if not agent:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=404, detail=f"Agent with ID {agent_id} not found"
|
||||
)
|
||||
# Use .jpeg here since we are generating JPEG images
|
||||
filename = f"agent_{agent_id}.jpeg"
|
||||
|
||||
existing_url = await backend.server.v2.store.media.check_media_exists(
|
||||
user_id, filename
|
||||
)
|
||||
if existing_url:
|
||||
logger.info(f"Using existing image for agent {agent_id}")
|
||||
return fastapi.responses.JSONResponse(content={"image_url": existing_url})
|
||||
# Generate agent image as JPEG
|
||||
image = await backend.server.v2.store.image_gen.generate_agent_image(
|
||||
agent=agent
|
||||
)
|
||||
existing_url = await backend.server.v2.store.media.check_media_exists(
|
||||
user_id, filename
|
||||
)
|
||||
if existing_url:
|
||||
logger.info(f"Using existing image for agent {agent_id}")
|
||||
return fastapi.responses.JSONResponse(content={"image_url": existing_url})
|
||||
# Generate agent image as JPEG
|
||||
image = await backend.server.v2.store.image_gen.generate_agent_image(agent=agent)
|
||||
|
||||
# Create UploadFile with the correct filename and content_type
|
||||
image_file = fastapi.UploadFile(
|
||||
file=image,
|
||||
filename=filename,
|
||||
)
|
||||
# Create UploadFile with the correct filename and content_type
|
||||
image_file = fastapi.UploadFile(
|
||||
file=image,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
image_url = await backend.server.v2.store.media.upload_media(
|
||||
user_id=user_id, file=image_file, use_file_name=True
|
||||
)
|
||||
image_url = await backend.server.v2.store.media.upload_media(
|
||||
user_id=user_id, file=image_file, use_file_name=True
|
||||
)
|
||||
|
||||
return fastapi.responses.JSONResponse(content={"image_url": image_url})
|
||||
except Exception:
|
||||
logger.exception("Exception occurred whilst generating submission image")
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while generating the image"},
|
||||
)
|
||||
return fastapi.responses.JSONResponse(content={"image_url": image_url})
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -329,7 +329,3 @@ class WebsocketServer(AppProcess):
|
||||
port=Config().websocket_server_port,
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
logger.info(f"[{self.service_name}] ⏳ Shutting down WebSocket Server...")
|
||||
|
||||
@@ -63,9 +63,9 @@ def initialize_launchdarkly() -> None:
|
||||
config = Config(sdk_key)
|
||||
ldclient.set_config(config)
|
||||
|
||||
global _is_initialized
|
||||
_is_initialized = True
|
||||
if ldclient.get().is_initialized():
|
||||
global _is_initialized
|
||||
_is_initialized = True
|
||||
logger.info("LaunchDarkly client initialized successfully")
|
||||
else:
|
||||
logger.error("LaunchDarkly client failed to initialize")
|
||||
@@ -218,7 +218,8 @@ def feature_flag(
|
||||
|
||||
if not get_client().is_initialized():
|
||||
logger.warning(
|
||||
f"LaunchDarkly not initialized, using default={default}"
|
||||
"LaunchDarkly not initialized, "
|
||||
f"using default {flag_key}={repr(default)}"
|
||||
)
|
||||
is_enabled = default
|
||||
else:
|
||||
@@ -232,8 +233,9 @@ def feature_flag(
|
||||
else:
|
||||
# Log warning and use default for non-boolean values
|
||||
logger.warning(
|
||||
f"Feature flag {flag_key} returned non-boolean value: {flag_value} (type: {type(flag_value).__name__}). "
|
||||
f"Using default={default}"
|
||||
f"Feature flag {flag_key} returned non-boolean value: "
|
||||
f"{repr(flag_value)} (type: {type(flag_value).__name__}). "
|
||||
f"Using default value {repr(default)}"
|
||||
)
|
||||
is_enabled = default
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ settings = Settings()
|
||||
def configure_logging():
|
||||
import autogpt_libs.logging.config
|
||||
|
||||
if (
|
||||
settings.config.behave_as == BehaveAs.LOCAL
|
||||
or settings.config.app_env == AppEnvironment.LOCAL
|
||||
):
|
||||
if not is_structured_logging_enabled():
|
||||
autogpt_libs.logging.config.configure_logging(force_cloud_logging=False)
|
||||
else:
|
||||
autogpt_libs.logging.config.configure_logging(force_cloud_logging=True)
|
||||
@@ -20,6 +17,14 @@ def configure_logging():
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def is_structured_logging_enabled() -> bool:
|
||||
"""Check if structured logging (cloud logging) is enabled."""
|
||||
return not (
|
||||
settings.config.behave_as == BehaveAs.LOCAL
|
||||
or settings.config.app_env == AppEnvironment.LOCAL
|
||||
)
|
||||
|
||||
|
||||
class TruncatedLogger:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -3,15 +3,17 @@ from enum import Enum
|
||||
|
||||
import sentry_sdk
|
||||
from pydantic import SecretStr
|
||||
from sentry_sdk.integrations import DidNotEnable
|
||||
from sentry_sdk.integrations.anthropic import AnthropicIntegration
|
||||
from sentry_sdk.integrations.asyncio import AsyncioIntegration
|
||||
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
from backend.util.feature_flag import get_client, is_configured
|
||||
from backend.util import feature_flag
|
||||
from backend.util.settings import Settings
|
||||
|
||||
settings = Settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiscordChannel(str, Enum):
|
||||
@@ -22,8 +24,11 @@ class DiscordChannel(str, Enum):
|
||||
def sentry_init():
|
||||
sentry_dsn = settings.secrets.sentry_dsn
|
||||
integrations = []
|
||||
if is_configured():
|
||||
integrations.append(LaunchDarklyIntegration(get_client()))
|
||||
if feature_flag.is_configured():
|
||||
try:
|
||||
integrations.append(LaunchDarklyIntegration(feature_flag.get_client()))
|
||||
except DidNotEnable as e:
|
||||
logger.error(f"Error enabling LaunchDarklyIntegration for Sentry: {e}")
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
traces_sample_rate=1.0,
|
||||
|
||||
@@ -19,7 +19,8 @@ class AppProcess(ABC):
|
||||
"""
|
||||
|
||||
process: Optional[Process] = None
|
||||
cleaned_up = False
|
||||
_shutting_down: bool = False
|
||||
_cleaned_up: bool = False
|
||||
|
||||
if "forkserver" in get_all_start_methods():
|
||||
set_start_method("forkserver", force=True)
|
||||
@@ -43,7 +44,6 @@ class AppProcess(ABC):
|
||||
def service_name(self) -> str:
|
||||
return self.__class__.__name__
|
||||
|
||||
@abstractmethod
|
||||
def cleanup(self):
|
||||
"""
|
||||
Implement this method on a subclass to do post-execution cleanup,
|
||||
@@ -65,7 +65,8 @@ class AppProcess(ABC):
|
||||
self.run()
|
||||
except BaseException as e:
|
||||
logger.warning(
|
||||
f"[{self.service_name}] Termination request: {type(e).__name__}; {e} executing cleanup."
|
||||
f"[{self.service_name}] 🛑 Terminating because of {type(e).__name__}: {e}", # noqa
|
||||
exc_info=e if not isinstance(e, SystemExit) else None,
|
||||
)
|
||||
# Send error to Sentry before cleanup
|
||||
if not isinstance(e, (KeyboardInterrupt, SystemExit)):
|
||||
@@ -76,8 +77,12 @@ class AppProcess(ABC):
|
||||
except Exception:
|
||||
pass # Silently ignore if Sentry isn't available
|
||||
finally:
|
||||
self.cleanup()
|
||||
logger.info(f"[{self.service_name}] Terminated.")
|
||||
if not self._cleaned_up:
|
||||
self._cleaned_up = True
|
||||
logger.info(f"[{self.service_name}] 🧹 Running cleanup")
|
||||
self.cleanup()
|
||||
logger.info(f"[{self.service_name}] ✅ Cleanup done")
|
||||
logger.info(f"[{self.service_name}] 🛑 Terminated")
|
||||
|
||||
@staticmethod
|
||||
def llprint(message: str):
|
||||
@@ -88,8 +93,8 @@ class AppProcess(ABC):
|
||||
os.write(sys.stdout.fileno(), (message + "\n").encode())
|
||||
|
||||
def _self_terminate(self, signum: int, frame):
|
||||
if not self.cleaned_up:
|
||||
self.cleaned_up = True
|
||||
if not self._shutting_down:
|
||||
self._shutting_down = True
|
||||
sys.exit(0)
|
||||
else:
|
||||
self.llprint(
|
||||
|
||||
@@ -175,10 +175,15 @@ async def validate_url(
|
||||
f"for hostname {ascii_hostname} is not allowed."
|
||||
)
|
||||
|
||||
# Reconstruct the netloc with IDNA-encoded hostname and preserve port
|
||||
netloc = ascii_hostname
|
||||
if parsed.port:
|
||||
netloc = f"{ascii_hostname}:{parsed.port}"
|
||||
|
||||
return (
|
||||
URL(
|
||||
parsed.scheme,
|
||||
ascii_hostname,
|
||||
netloc,
|
||||
quote(parsed.path, safe="/%:@"),
|
||||
parsed.params,
|
||||
parsed.query,
|
||||
|
||||
@@ -4,9 +4,12 @@ import concurrent.futures
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import update_wrapper
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -111,14 +114,44 @@ class BaseAppService(AppProcess, ABC):
|
||||
return target_host
|
||||
|
||||
def run_service(self) -> None:
|
||||
while True:
|
||||
time.sleep(10)
|
||||
# HACK: run the main event loop outside the main thread to disable Uvicorn's
|
||||
# internal signal handlers, since there is no config option for this :(
|
||||
shared_asyncio_thread = threading.Thread(
|
||||
target=self._run_shared_event_loop,
|
||||
daemon=True,
|
||||
name=f"{self.service_name}-shared-event-loop",
|
||||
)
|
||||
shared_asyncio_thread.start()
|
||||
shared_asyncio_thread.join()
|
||||
|
||||
def _run_shared_event_loop(self) -> None:
|
||||
try:
|
||||
self.shared_event_loop.run_forever()
|
||||
finally:
|
||||
logger.info(f"[{self.service_name}] 🛑 Shared event loop stopped")
|
||||
self.shared_event_loop.close() # ensure held resources are released
|
||||
|
||||
def run_and_wait(self, coro: Coroutine[Any, Any, T]) -> T:
|
||||
return asyncio.run_coroutine_threadsafe(coro, self.shared_event_loop).result()
|
||||
|
||||
def run(self):
|
||||
self.shared_event_loop = asyncio.get_event_loop()
|
||||
self.shared_event_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.shared_event_loop)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
**💡 Overriding `AppService.lifespan` may be a more convenient option.**
|
||||
|
||||
Implement this method on a subclass to do post-execution cleanup,
|
||||
e.g. disconnecting from a database or terminating child processes.
|
||||
|
||||
**Note:** if you override this method in a subclass, it must call
|
||||
`super().cleanup()` *at the end*!
|
||||
"""
|
||||
# Stop the shared event loop to allow resource clean-up
|
||||
self.shared_event_loop.call_soon_threadsafe(self.shared_event_loop.stop)
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class RemoteCallError(BaseModel):
|
||||
@@ -179,6 +212,7 @@ EXCEPTION_MAPPING = {
|
||||
|
||||
class AppService(BaseAppService, ABC):
|
||||
fastapi_app: FastAPI
|
||||
http_server: uvicorn.Server | None = None
|
||||
log_level: str = "info"
|
||||
|
||||
def set_log_level(self, log_level: str):
|
||||
@@ -190,11 +224,10 @@ class AppService(BaseAppService, ABC):
|
||||
def _handle_internal_http_error(status_code: int = 500, log_error: bool = True):
|
||||
def handler(request: Request, exc: Exception):
|
||||
if log_error:
|
||||
if status_code == 500:
|
||||
log = logger.exception
|
||||
else:
|
||||
log = logger.error
|
||||
log(f"{request.method} {request.url.path} failed: {exc}")
|
||||
logger.error(
|
||||
f"{request.method} {request.url.path} failed: {exc}",
|
||||
exc_info=exc if status_code == 500 else None,
|
||||
)
|
||||
return responses.JSONResponse(
|
||||
status_code=status_code,
|
||||
content=RemoteCallError(
|
||||
@@ -256,13 +289,13 @@ class AppService(BaseAppService, ABC):
|
||||
|
||||
return sync_endpoint
|
||||
|
||||
@conn_retry("FastAPI server", "Starting FastAPI server")
|
||||
@conn_retry("FastAPI server", "Running FastAPI server")
|
||||
def __start_fastapi(self):
|
||||
logger.info(
|
||||
f"[{self.service_name}] Starting RPC server at http://{api_host}:{self.get_port()}"
|
||||
)
|
||||
|
||||
server = uvicorn.Server(
|
||||
self.http_server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
self.fastapi_app,
|
||||
host=api_host,
|
||||
@@ -271,18 +304,76 @@ class AppService(BaseAppService, ABC):
|
||||
log_level=self.log_level,
|
||||
)
|
||||
)
|
||||
self.shared_event_loop.run_until_complete(server.serve())
|
||||
self.run_and_wait(self.http_server.serve())
|
||||
|
||||
# Perform clean-up when the server exits
|
||||
if not self._cleaned_up:
|
||||
self._cleaned_up = True
|
||||
logger.info(f"[{self.service_name}] 🧹 Running cleanup")
|
||||
self.cleanup()
|
||||
logger.info(f"[{self.service_name}] ✅ Cleanup done")
|
||||
|
||||
def _self_terminate(self, signum: int, frame):
|
||||
"""Pass SIGTERM to Uvicorn so it can shut down gracefully"""
|
||||
signame = signal.Signals(signum).name
|
||||
if not self._shutting_down:
|
||||
self._shutting_down = True
|
||||
if self.http_server:
|
||||
logger.info(
|
||||
f"[{self.service_name}] 🛑 Received {signame} ({signum}) - "
|
||||
"Entering RPC server graceful shutdown"
|
||||
)
|
||||
self.http_server.handle_exit(signum, frame) # stop accepting requests
|
||||
|
||||
# NOTE: Actually stopping the process is triggered by:
|
||||
# 1. The call to self.cleanup() at the end of __start_fastapi() 👆🏼
|
||||
# 2. BaseAppService.cleanup() stopping the shared event loop
|
||||
else:
|
||||
logger.warning(
|
||||
f"[{self.service_name}] {signame} received before HTTP server init."
|
||||
" Terminating..."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
else:
|
||||
# Expedite shutdown on second SIGTERM
|
||||
logger.info(
|
||||
f"[{self.service_name}] 🛑🛑 Received {signame} ({signum}), "
|
||||
"but shutdown is already underway. Terminating..."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self, app: FastAPI):
|
||||
"""
|
||||
The FastAPI/Uvicorn server's lifespan manager, used for setup and shutdown.
|
||||
|
||||
You can extend and use this in a subclass like:
|
||||
```
|
||||
@asynccontextmanager
|
||||
async def lifespan(self, app: FastAPI):
|
||||
async with super().lifespan(app):
|
||||
await db.connect()
|
||||
yield
|
||||
await db.disconnect()
|
||||
```
|
||||
"""
|
||||
# Startup - this runs before Uvicorn starts accepting connections
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown - this runs when FastAPI/Uvicorn shuts down
|
||||
logger.info(f"[{self.service_name}] ✅ FastAPI has finished")
|
||||
|
||||
async def health_check(self) -> str:
|
||||
"""
|
||||
A method to check the health of the process.
|
||||
"""
|
||||
"""A method to check the health of the process."""
|
||||
return "OK"
|
||||
|
||||
def run(self):
|
||||
sentry_init()
|
||||
super().run()
|
||||
self.fastapi_app = FastAPI()
|
||||
|
||||
self.fastapi_app = FastAPI(lifespan=self.lifespan)
|
||||
|
||||
# Add Prometheus instrumentation to all services
|
||||
try:
|
||||
@@ -325,7 +416,11 @@ class AppService(BaseAppService, ABC):
|
||||
)
|
||||
|
||||
# Start the FastAPI server in a separate thread.
|
||||
api_thread = threading.Thread(target=self.__start_fastapi, daemon=True)
|
||||
api_thread = threading.Thread(
|
||||
target=self.__start_fastapi,
|
||||
daemon=True,
|
||||
name=f"{self.service_name}-http-server",
|
||||
)
|
||||
api_thread.start()
|
||||
|
||||
# Run the main service loop (blocking).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from functools import cached_property
|
||||
from unittest.mock import Mock
|
||||
@@ -18,20 +20,11 @@ from backend.util.service import (
|
||||
TEST_SERVICE_PORT = 8765
|
||||
|
||||
|
||||
def wait_for_service_ready(service_client_type, timeout_seconds=30):
|
||||
"""Helper method to wait for a service to be ready using health check with retry."""
|
||||
client = get_service_client(service_client_type, request_retry=True)
|
||||
client.health_check() # This will retry until service is ready
|
||||
|
||||
|
||||
class ServiceTest(AppService):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.fail_count = 0
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_port(cls) -> int:
|
||||
return TEST_SERVICE_PORT
|
||||
@@ -41,10 +34,17 @@ class ServiceTest(AppService):
|
||||
result = super().__enter__()
|
||||
|
||||
# Wait for the service to be ready
|
||||
wait_for_service_ready(ServiceTestClient)
|
||||
self.wait_until_ready()
|
||||
|
||||
return result
|
||||
|
||||
def wait_until_ready(self, timeout_seconds: int = 5):
|
||||
"""Helper method to wait for a service to be ready using health check with retry."""
|
||||
client = get_service_client(
|
||||
ServiceTestClient, call_timeout=timeout_seconds, request_retry=True
|
||||
)
|
||||
client.health_check() # This will retry until service is ready\
|
||||
|
||||
@expose
|
||||
def add(self, a: int, b: int) -> int:
|
||||
return a + b
|
||||
@@ -490,3 +490,167 @@ class TestHTTPErrorRetryBehavior:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status_code
|
||||
|
||||
|
||||
class TestGracefulShutdownService(AppService):
|
||||
"""Test service with slow endpoints for testing graceful shutdown"""
|
||||
|
||||
@classmethod
|
||||
def get_port(cls) -> int:
|
||||
return 18999 # Use a specific test port
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.request_log = []
|
||||
self.cleanup_called = False
|
||||
self.cleanup_completed = False
|
||||
|
||||
@expose
|
||||
async def slow_endpoint(self, duration: int = 5) -> dict:
|
||||
"""Endpoint that takes time to complete"""
|
||||
start_time = time.time()
|
||||
self.request_log.append(f"slow_endpoint started at {start_time}")
|
||||
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
end_time = time.time()
|
||||
result = {
|
||||
"message": "completed",
|
||||
"duration": end_time - start_time,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
}
|
||||
self.request_log.append(f"slow_endpoint completed at {end_time}")
|
||||
return result
|
||||
|
||||
@expose
|
||||
def fast_endpoint(self) -> dict:
|
||||
"""Fast endpoint for testing rejection during shutdown"""
|
||||
timestamp = time.time()
|
||||
self.request_log.append(f"fast_endpoint called at {timestamp}")
|
||||
return {"message": "fast", "timestamp": timestamp}
|
||||
|
||||
def cleanup(self):
|
||||
"""Override cleanup to track when it's called"""
|
||||
self.cleanup_called = True
|
||||
self.request_log.append(f"cleanup started at {time.time()}")
|
||||
|
||||
# Call parent cleanup
|
||||
super().cleanup()
|
||||
|
||||
self.cleanup_completed = True
|
||||
self.request_log.append(f"cleanup completed at {time.time()}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def test_service():
|
||||
"""Run the test service in a separate process"""
|
||||
|
||||
service = TestGracefulShutdownService()
|
||||
service.start(background=True)
|
||||
|
||||
base_url = f"http://localhost:{service.get_port()}"
|
||||
|
||||
await wait_until_service_ready(base_url)
|
||||
yield service, base_url
|
||||
|
||||
service.stop()
|
||||
|
||||
|
||||
async def wait_until_service_ready(base_url: str, timeout: float = 10):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time <= timeout:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
with contextlib.suppress(httpx.ConnectError):
|
||||
response = await client.get(f"{base_url}/health_check", timeout=5)
|
||||
|
||||
if response.status_code == 200 and response.json() == "OK":
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
raise RuntimeError(f"Service at {base_url} not available after {timeout} seconds")
|
||||
|
||||
|
||||
async def send_slow_request(base_url: str) -> dict:
|
||||
"""Send a slow request and return the result"""
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(f"{base_url}/slow_endpoint", json={"duration": 5})
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graceful_shutdown(test_service):
|
||||
"""Test that AppService handles graceful shutdown correctly"""
|
||||
service, test_service_url = test_service
|
||||
|
||||
# Start a slow request that should complete even after shutdown
|
||||
slow_task = asyncio.create_task(send_slow_request(test_service_url))
|
||||
|
||||
# Give the slow request time to start
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Send SIGTERM to the service process
|
||||
shutdown_start_time = time.time()
|
||||
service.process.terminate() # This sends SIGTERM
|
||||
|
||||
# Wait a moment for shutdown to start
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Try to send a new request - should be rejected or connection refused
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.post(f"{test_service_url}/fast_endpoint", json={})
|
||||
# Should get 503 Service Unavailable during shutdown
|
||||
assert response.status_code == 503
|
||||
assert "shutting down" in response.json()["detail"].lower()
|
||||
except httpx.ConnectError:
|
||||
# Connection refused is also acceptable - server stopped accepting
|
||||
pass
|
||||
|
||||
# The slow request should still complete successfully
|
||||
slow_result = await slow_task
|
||||
assert slow_result["message"] == "completed"
|
||||
assert 4.9 < slow_result["duration"] < 5.5 # Should have taken ~5 seconds
|
||||
|
||||
# Wait for the service to fully shut down
|
||||
service.process.join(timeout=15)
|
||||
shutdown_end_time = time.time()
|
||||
|
||||
# Verify the service actually terminated
|
||||
assert not service.process.is_alive()
|
||||
|
||||
# Verify shutdown took reasonable time (slow request - 1s + cleanup)
|
||||
shutdown_duration = shutdown_end_time - shutdown_start_time
|
||||
assert 4 <= shutdown_duration <= 6 # ~5s request - 1s + buffer
|
||||
|
||||
print(f"Shutdown took {shutdown_duration:.2f} seconds")
|
||||
print(f"Slow request completed in: {slow_result['duration']:.2f} seconds")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_during_shutdown(test_service):
|
||||
"""Test that health checks behave correctly during shutdown"""
|
||||
service, test_service_url = test_service
|
||||
|
||||
# Health check should pass initially
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{test_service_url}/health_check")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Send SIGTERM
|
||||
service.process.terminate()
|
||||
|
||||
# Wait for shutdown to begin
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Health check should now fail or connection should be refused
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{test_service_url}/health_check")
|
||||
# Could either get 503, 500 (unhealthy), or connection error
|
||||
assert response.status_code in [500, 503]
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout):
|
||||
# Connection refused/timeout is also acceptable
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import bleach
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
from jinja2 import BaseLoader
|
||||
from jinja2.exceptions import TemplateError
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from markupsafe import Markup
|
||||
|
||||
@@ -101,8 +102,11 @@ class TextFormatter:
|
||||
|
||||
def format_string(self, template_str: str, values=None, **kwargs) -> str:
|
||||
"""Regular template rendering with escaping"""
|
||||
template = self.env.from_string(template_str)
|
||||
return template.render(values or {}, **kwargs)
|
||||
try:
|
||||
template = self.env.from_string(template_str)
|
||||
return template.render(values or {}, **kwargs)
|
||||
except TemplateError as e:
|
||||
raise ValueError(e) from e
|
||||
|
||||
def format_email(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreListingVersion" ADD COLUMN "search" tsvector DEFAULT ''::tsvector;
|
||||
|
||||
-- Add trigger to update the search column with the tsvector of the agent
|
||||
-- Function to be invoked by trigger
|
||||
|
||||
-- Drop the trigger first
|
||||
DROP TRIGGER IF EXISTS "update_tsvector" ON "StoreListingVersion";
|
||||
|
||||
-- Drop the function completely
|
||||
DROP FUNCTION IF EXISTS update_tsvector_column();
|
||||
|
||||
-- Now recreate it fresh
|
||||
CREATE OR REPLACE FUNCTION update_tsvector_column() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search := to_tsvector('english',
|
||||
COALESCE(NEW.name, '') || ' ' ||
|
||||
COALESCE(NEW.description, '') || ' ' ||
|
||||
COALESCE(NEW."subHeading", '')
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = platform, pg_temp;
|
||||
|
||||
-- Recreate the trigger
|
||||
CREATE TRIGGER "update_tsvector"
|
||||
BEFORE INSERT OR UPDATE ON "StoreListingVersion"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_tsvector_column();
|
||||
|
||||
UPDATE "StoreListingVersion"
|
||||
SET search = to_tsvector('english',
|
||||
COALESCE(name, '') || ' ' ||
|
||||
COALESCE(description, '') || ' ' ||
|
||||
COALESCE("subHeading", '')
|
||||
)
|
||||
WHERE search IS NULL;
|
||||
|
||||
-- Drop and recreate the StoreAgent view with isAvailable field
|
||||
DROP VIEW IF EXISTS "StoreAgent";
|
||||
|
||||
CREATE OR REPLACE VIEW "StoreAgent" AS
|
||||
WITH latest_versions AS (
|
||||
SELECT
|
||||
"storeListingId",
|
||||
MAX(version) AS max_version
|
||||
FROM "StoreListingVersion"
|
||||
WHERE "submissionStatus" = 'APPROVED'
|
||||
GROUP BY "storeListingId"
|
||||
),
|
||||
agent_versions AS (
|
||||
SELECT
|
||||
"storeListingId",
|
||||
array_agg(DISTINCT version::text ORDER BY version::text) AS versions
|
||||
FROM "StoreListingVersion"
|
||||
WHERE "submissionStatus" = 'APPROVED'
|
||||
GROUP BY "storeListingId"
|
||||
)
|
||||
SELECT
|
||||
sl.id AS listing_id,
|
||||
slv.id AS "storeListingVersionId",
|
||||
slv."createdAt" AS updated_at,
|
||||
sl.slug,
|
||||
COALESCE(slv.name, '') AS agent_name,
|
||||
slv."videoUrl" AS agent_video,
|
||||
COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image,
|
||||
slv."isFeatured" AS featured,
|
||||
p.username AS creator_username, -- Allow NULL for malformed sub-agents
|
||||
p."avatarUrl" AS creator_avatar, -- Allow NULL for malformed sub-agents
|
||||
slv."subHeading" AS sub_heading,
|
||||
slv.description,
|
||||
slv.categories,
|
||||
slv.search,
|
||||
COALESCE(ar.run_count, 0::bigint) AS runs,
|
||||
COALESCE(rs.avg_rating, 0.0)::double precision AS rating,
|
||||
COALESCE(av.versions, ARRAY[slv.version::text]) AS versions,
|
||||
COALESCE(sl."useForOnboarding", false) AS "useForOnboarding",
|
||||
slv."isAvailable" AS is_available -- Add isAvailable field to filter sub-agents
|
||||
FROM "StoreListing" sl
|
||||
JOIN latest_versions lv
|
||||
ON sl.id = lv."storeListingId"
|
||||
JOIN "StoreListingVersion" slv
|
||||
ON slv."storeListingId" = lv."storeListingId"
|
||||
AND slv.version = lv.max_version
|
||||
AND slv."submissionStatus" = 'APPROVED'
|
||||
JOIN "AgentGraph" a
|
||||
ON slv."agentGraphId" = a.id
|
||||
AND slv."agentGraphVersion" = a.version
|
||||
LEFT JOIN "Profile" p
|
||||
ON sl."owningUserId" = p."userId"
|
||||
LEFT JOIN "mv_review_stats" rs
|
||||
ON sl.id = rs."storeListingId"
|
||||
LEFT JOIN "mv_agent_run_counts" ar
|
||||
ON a.id = ar."agentGraphId"
|
||||
LEFT JOIN agent_versions av
|
||||
ON sl.id = av."storeListingId"
|
||||
WHERE sl."isDeleted" = false
|
||||
AND sl."hasApprovedVersion" = true;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Migrate Claude 3.5 models to Claude 4.5 models
|
||||
-- This updates all AgentNode blocks that use deprecated Claude 3.5 models to the new 4.5 models
|
||||
-- See: https://docs.anthropic.com/en/docs/about-claude/models/legacy-model-guide
|
||||
|
||||
-- Update Claude 3.5 Sonnet to Claude 4.5 Sonnet
|
||||
UPDATE "AgentNode"
|
||||
SET "constantInput" = JSONB_SET(
|
||||
"constantInput"::jsonb,
|
||||
'{model}',
|
||||
'"claude-sonnet-4-5-20250929"'::jsonb
|
||||
)
|
||||
WHERE "constantInput"::jsonb->>'model' = 'claude-3-5-sonnet-latest';
|
||||
|
||||
-- Update Claude 3.5 Haiku to Claude 4.5 Haiku
|
||||
UPDATE "AgentNode"
|
||||
SET "constantInput" = JSONB_SET(
|
||||
"constantInput"::jsonb,
|
||||
'{model}',
|
||||
'"claude-haiku-4-5-20251001"'::jsonb
|
||||
)
|
||||
WHERE "constantInput"::jsonb->>'model' = 'claude-3-5-haiku-latest';
|
||||
@@ -5,10 +5,11 @@ datasource db {
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-py"
|
||||
recursive_type_depth = -1
|
||||
interface = "asyncio"
|
||||
previewFeatures = ["views"]
|
||||
provider = "prisma-client-py"
|
||||
recursive_type_depth = -1
|
||||
interface = "asyncio"
|
||||
previewFeatures = ["views", "fullTextSearch"]
|
||||
partial_type_generator = "backend/data/partial_types.py"
|
||||
}
|
||||
|
||||
// User model to mirror Auth provider users
|
||||
@@ -664,6 +665,7 @@ view StoreAgent {
|
||||
sub_heading String
|
||||
description String
|
||||
categories String[]
|
||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
||||
runs Int
|
||||
rating Float
|
||||
versions String[]
|
||||
@@ -747,7 +749,7 @@ model StoreListing {
|
||||
slug String
|
||||
|
||||
// Allow this agent to be used during onboarding
|
||||
useForOnboarding Boolean @default(false)
|
||||
useForOnboarding Boolean @default(false)
|
||||
|
||||
// The currently active version that should be shown to users
|
||||
activeVersionId String? @unique
|
||||
@@ -798,6 +800,8 @@ model StoreListingVersion {
|
||||
// Old versions can be made unavailable by the author if desired
|
||||
isAvailable Boolean @default(true)
|
||||
|
||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
||||
|
||||
// Version workflow state
|
||||
submissionStatus SubmissionStatus @default(DRAFT)
|
||||
submittedAt DateTime?
|
||||
|
||||
140
autogpt_platform/backend/test/blocks/test_youtube.py
Normal file
140
autogpt_platform/backend/test/blocks/test_youtube.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from youtube_transcript_api._errors import NoTranscriptFound
|
||||
from youtube_transcript_api._transcripts import FetchedTranscript, Transcript
|
||||
|
||||
from backend.blocks.youtube import TranscribeYoutubeVideoBlock
|
||||
|
||||
|
||||
class TestTranscribeYoutubeVideoBlock:
|
||||
"""Test cases for TranscribeYoutubeVideoBlock language fallback functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.youtube_block = TranscribeYoutubeVideoBlock()
|
||||
|
||||
def test_extract_video_id_standard_url(self):
|
||||
"""Test extracting video ID from standard YouTube URL."""
|
||||
url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dQw4w9WgXcQ"
|
||||
|
||||
def test_extract_video_id_short_url(self):
|
||||
"""Test extracting video ID from shortened youtu.be URL."""
|
||||
url = "https://youtu.be/dQw4w9WgXcQ"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dQw4w9WgXcQ"
|
||||
|
||||
def test_extract_video_id_embed_url(self):
|
||||
"""Test extracting video ID from embed URL."""
|
||||
url = "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dQw4w9WgXcQ"
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_english_available(self, mock_api_class):
|
||||
"""Test getting transcript when English is available."""
|
||||
# Setup mock
|
||||
mock_api = Mock()
|
||||
mock_api_class.return_value = mock_api
|
||||
mock_transcript = Mock(spec=FetchedTranscript)
|
||||
mock_api.fetch.return_value = mock_transcript
|
||||
|
||||
# Execute
|
||||
result = TranscribeYoutubeVideoBlock.get_transcript("test_video_id")
|
||||
|
||||
# Assert
|
||||
assert result == mock_transcript
|
||||
mock_api.fetch.assert_called_once_with(video_id="test_video_id")
|
||||
mock_api.list.assert_not_called()
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_fallback_to_first_available(self, mock_api_class):
|
||||
"""Test fallback to first available language when English is not available."""
|
||||
# Setup mock
|
||||
mock_api = Mock()
|
||||
mock_api_class.return_value = mock_api
|
||||
|
||||
# Create mock transcript list with Hungarian transcript
|
||||
mock_transcript_list = Mock()
|
||||
mock_transcript_hu = Mock(spec=Transcript)
|
||||
mock_fetched_transcript = Mock(spec=FetchedTranscript)
|
||||
mock_transcript_hu.fetch.return_value = mock_fetched_transcript
|
||||
|
||||
# Set up the transcript list to have manually created transcripts empty
|
||||
# and generated transcripts with Hungarian
|
||||
mock_transcript_list._manually_created_transcripts = {}
|
||||
mock_transcript_list._generated_transcripts = {"hu": mock_transcript_hu}
|
||||
|
||||
# Mock API to raise NoTranscriptFound for English, then return list
|
||||
mock_api.fetch.side_effect = NoTranscriptFound(
|
||||
"test_video_id", ("en",), mock_transcript_list
|
||||
)
|
||||
mock_api.list.return_value = mock_transcript_list
|
||||
|
||||
# Execute
|
||||
result = TranscribeYoutubeVideoBlock.get_transcript("test_video_id")
|
||||
|
||||
# Assert
|
||||
assert result == mock_fetched_transcript
|
||||
mock_api.fetch.assert_called_once_with(video_id="test_video_id")
|
||||
mock_api.list.assert_called_once_with("test_video_id")
|
||||
mock_transcript_hu.fetch.assert_called_once()
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_prefers_manually_created(self, mock_api_class):
|
||||
"""Test that manually created transcripts are preferred over generated ones."""
|
||||
# Setup mock
|
||||
mock_api = Mock()
|
||||
mock_api_class.return_value = mock_api
|
||||
|
||||
# Create mock transcript list with both manual and generated transcripts
|
||||
mock_transcript_list = Mock()
|
||||
mock_transcript_manual = Mock(spec=Transcript)
|
||||
mock_transcript_generated = Mock(spec=Transcript)
|
||||
mock_fetched_manual = Mock(spec=FetchedTranscript)
|
||||
mock_transcript_manual.fetch.return_value = mock_fetched_manual
|
||||
|
||||
# Set up the transcript list
|
||||
mock_transcript_list._manually_created_transcripts = {
|
||||
"es": mock_transcript_manual
|
||||
}
|
||||
mock_transcript_list._generated_transcripts = {"hu": mock_transcript_generated}
|
||||
|
||||
# Mock API to raise NoTranscriptFound for English
|
||||
mock_api.fetch.side_effect = NoTranscriptFound(
|
||||
"test_video_id", ("en",), mock_transcript_list
|
||||
)
|
||||
mock_api.list.return_value = mock_transcript_list
|
||||
|
||||
# Execute
|
||||
result = TranscribeYoutubeVideoBlock.get_transcript("test_video_id")
|
||||
|
||||
# Assert - should use manually created transcript first
|
||||
assert result == mock_fetched_manual
|
||||
mock_transcript_manual.fetch.assert_called_once()
|
||||
mock_transcript_generated.fetch.assert_not_called()
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_no_transcripts_available(self, mock_api_class):
|
||||
"""Test that exception is re-raised when no transcripts are available at all."""
|
||||
# Setup mock
|
||||
mock_api = Mock()
|
||||
mock_api_class.return_value = mock_api
|
||||
|
||||
# Create mock transcript list with no transcripts
|
||||
mock_transcript_list = Mock()
|
||||
mock_transcript_list._manually_created_transcripts = {}
|
||||
mock_transcript_list._generated_transcripts = {}
|
||||
|
||||
# Mock API to raise NoTranscriptFound
|
||||
original_exception = NoTranscriptFound(
|
||||
"test_video_id", ("en",), mock_transcript_list
|
||||
)
|
||||
mock_api.fetch.side_effect = original_exception
|
||||
mock_api.list.return_value = mock_transcript_list
|
||||
|
||||
# Execute and assert exception is raised
|
||||
with pytest.raises(NoTranscriptFound):
|
||||
TranscribeYoutubeVideoBlock.get_transcript("test_video_id")
|
||||
@@ -21,6 +21,7 @@ import random
|
||||
from datetime import datetime
|
||||
|
||||
import prisma.enums
|
||||
import pytest
|
||||
from autogpt_libs.api_key.keysmith import APIKeySmith
|
||||
from faker import Faker
|
||||
from prisma import Json, Prisma
|
||||
@@ -498,9 +499,6 @@ async def main():
|
||||
if store_listing_versions and random.random() < 0.5
|
||||
else None
|
||||
),
|
||||
"agentInput": (
|
||||
Json({"test": "data"}) if random.random() < 0.3 else None
|
||||
),
|
||||
"onboardingAgentExecutionId": (
|
||||
random.choice(agent_graph_executions).id
|
||||
if agent_graph_executions and random.random() < 0.3
|
||||
@@ -570,5 +568,11 @@ async def main():
|
||||
print("Test data creation completed successfully!")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_main_function_runs_without_errors():
|
||||
await main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
765
autogpt_platform/frontend/CONTRIBUTING.md
Normal file
765
autogpt_platform/frontend/CONTRIBUTING.md
Normal file
@@ -0,0 +1,765 @@
|
||||
<div align="center">
|
||||
<h1>AutoGPT Frontend • Contributing ⌨️</h1>
|
||||
<p>Next.js App Router • Client-first • Type-safe generated API hooks • Tailwind + shadcn/ui</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ☕️ Summary
|
||||
|
||||
This document is your reference for contributing to the AutoGPT Frontend. It adapts legacy guidelines to our current stack and practices.
|
||||
|
||||
- Architecture and stack
|
||||
- Component structure and design system
|
||||
- Data fetching (generated API hooks)
|
||||
- Feature flags
|
||||
- Naming and code conventions
|
||||
- Tooling, scripts, and testing
|
||||
- PR process and checklist
|
||||
|
||||
This is a living document. Open a pull request any time to improve it.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start FAQ
|
||||
|
||||
New to the codebase? Here are shortcuts to common tasks:
|
||||
|
||||
### I need to make a new page
|
||||
|
||||
1. Create page in `src/app/(platform)/your-feature/page.tsx`
|
||||
2. If it has logic, create `usePage.ts` hook next to it
|
||||
3. Create sub-components in `components/` folder
|
||||
4. Use generated API hooks for data fetching
|
||||
5. If page needs auth, ensure it's in the `(platform)` route group
|
||||
|
||||
**Example structure:**
|
||||
|
||||
```
|
||||
app/(platform)/dashboard/
|
||||
page.tsx
|
||||
useDashboardPage.ts
|
||||
components/
|
||||
StatsPanel/
|
||||
StatsPanel.tsx
|
||||
useStatsPanel.ts
|
||||
```
|
||||
|
||||
See [Component structure](#-component-structure) and [Styling](#-styling) and [Data fetching patterns](#-data-fetching-patterns) sections.
|
||||
|
||||
### I need to update an existing component in a page
|
||||
|
||||
1. Find the page `src/app/(platform)/your-feature/page.tsx`
|
||||
2. Check its `components/` folder
|
||||
3. If needing to update its logic, check the `use[Component].ts` hook
|
||||
4. If the update is related to rendering, check `[Component].tsx` file
|
||||
|
||||
See [Component structure](#-component-structure) and [Styling](#-styling) sections.
|
||||
|
||||
### I need to make a new API call and show it on the UI
|
||||
|
||||
1. Ensure the backend endpoint exists in the OpenAPI spec
|
||||
2. Regenerate API client: `pnpm generate:api`
|
||||
3. Import the generated hook by typing the operation name (auto-import)
|
||||
4. Use the hook in your component/custom hook
|
||||
5. Handle loading, error, and success states
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
import { useGetV2ListLibraryAgents } from "@/app/api/__generated__/endpoints/library/library";
|
||||
|
||||
export function useAgentList() {
|
||||
const { data, isLoading, isError, error } = useGetV2ListLibraryAgents();
|
||||
|
||||
return {
|
||||
agents: data?.data || [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
See [Data fetching patterns](#-data-fetching-patterns) for more examples.
|
||||
|
||||
### I need to create a new component in the Design System
|
||||
|
||||
1. Determine the atomic level: atom, molecule, or organism
|
||||
2. Create folder: `src/components/[level]/ComponentName/`
|
||||
3. Create `ComponentName.tsx` (render logic)
|
||||
4. If logic exists, create `useComponentName.ts`
|
||||
5. Create `ComponentName.stories.tsx` for Storybook
|
||||
6. Use Tailwind + design tokens (avoid hardcoded values)
|
||||
7. Only use Phosphor icons
|
||||
8. Test in Storybook: `pnpm storybook`
|
||||
9. Verify in Chromatic after PR
|
||||
|
||||
**Example structure:**
|
||||
|
||||
```
|
||||
src/components/molecules/DataCard/
|
||||
DataCard.tsx
|
||||
DataCard.stories.tsx
|
||||
useDataCard.ts
|
||||
```
|
||||
|
||||
See [Component structure](#-component-structure) and [Styling](#-styling) sections.
|
||||
|
||||
---
|
||||
|
||||
## 📟 Contribution process
|
||||
|
||||
### 1) Branch off `dev`
|
||||
|
||||
- Branch from `dev` for features and fixes
|
||||
- Keep PRs focused (aim for one ticket per PR)
|
||||
- Use conventional commit messages with a scope (e.g., `feat(frontend): add X`)
|
||||
|
||||
### 2) Feature flags
|
||||
|
||||
If a feature will ship across multiple PRs, guard it with a flag so we can merge iteratively.
|
||||
|
||||
- Use [LaunchDarkly](https://www.launchdarkly.com) based flags (see Feature Flags below)
|
||||
- Avoid long-lived feature branches
|
||||
|
||||
### 3) Open PR and get reviews ✅
|
||||
|
||||
Before requesting review:
|
||||
|
||||
- [x] Code follows architecture and conventions here
|
||||
- [x] `pnpm format && pnpm lint && pnpm types` pass
|
||||
- [x] Relevant tests pass locally: `pnpm test` (and/or Storybook tests)
|
||||
- [x] If touching UI, validate against our design system and stories
|
||||
|
||||
### 4) Merge to `dev`
|
||||
|
||||
- Use squash merges
|
||||
- Follow conventional commit message format for the squash title
|
||||
|
||||
---
|
||||
|
||||
## 📂 Architecture & Stack
|
||||
|
||||
### Next.js App Router
|
||||
|
||||
- We use the [Next.js App Router](https://nextjs.org/docs/app) in `src/app`
|
||||
- Use [route segments](https://nextjs.org/docs/app/building-your-application/routing) with semantic URLs; no `pages/`
|
||||
|
||||
### Component good practices
|
||||
|
||||
- Default to client components
|
||||
- Use server components only when:
|
||||
- SEO requires server-rendered HTML, or
|
||||
- Extreme first-byte performance justifies it
|
||||
- If you render server-side data, prefer server-side prefetch + client hydration (see examples below and [React Query SSR & Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr))
|
||||
- Prefer using [Next.js API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) when possible over [server actions](https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations)
|
||||
- Keep components small and simple
|
||||
- favour composition and splitting large components into smaller bits of UI
|
||||
- [colocate state](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster) when possible
|
||||
- keep render/side-effects split for [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns)
|
||||
- do not over-complicate or re-invent the wheel
|
||||
|
||||
**❓ Why a client-side first design vs server components/actions?**
|
||||
|
||||
While server components and actions are cool and cutting-edge, they introduce a layer of complexity which not always justified by the benefits they deliver. Defaulting to client-first keeps things simple in the mental model of the developer, specially for those developers less familiar with Next.js or heavy Front-end development.
|
||||
|
||||
### Data fetching: prefer generated API hooks
|
||||
|
||||
- We generate a type-safe client and React Query hooks from the backend OpenAPI spec via [Orval](https://orval.dev/)
|
||||
- Prefer the generated hooks under `src/app/api/__generated__/endpoints/...`
|
||||
- Treat `BackendAPI` and code under `src/lib/autogpt-server-api/*` as deprecated; do not introduce new usages
|
||||
- Use [Zod](https://zod.dev/) schemas from the generated client where applicable
|
||||
|
||||
### State management
|
||||
|
||||
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
|
||||
- Co-locate UI state inside components/hooks; keep global state minimal
|
||||
|
||||
### Styling and components
|
||||
|
||||
- [Tailwind CSS](https://tailwindcss.com/docs) + [shadcn/ui](https://ui.shadcn.com/) ([Radix Primitives](https://www.radix-ui.com/docs/primitives/overview/introduction) under the hood)
|
||||
- Use the design system under `src/components` for primitives and building blocks
|
||||
- Do not use anything under `src/components/_legacy__`; migrate away from it when touching old code
|
||||
- Reference the design system catalog on Chromatic: [`https://dev--670f94474adee5e32c896b98.chromatic.com/`](https://dev--670f94474adee5e32c896b98.chromatic.com/)
|
||||
- Use the [`tailwind-scrollbar`](https://www.npmjs.com/package/tailwind-scrollbar) plugin utilities for scrollbar styling
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Component structure
|
||||
|
||||
For components, separate render logic from data/behavior, and keep implementation details local.
|
||||
|
||||
**Most components should follow this structure.** Pages are just bigger components made of smaller ones, and sub-components can have their own nested sub-components when dealing with complex features.
|
||||
|
||||
### Basic structure
|
||||
|
||||
When a component has non-trivial logic:
|
||||
|
||||
```
|
||||
FeatureX/
|
||||
FeatureX.tsx (render logic only)
|
||||
useFeatureX.ts (hook; data fetching, behavior, state)
|
||||
helpers.ts (pure helpers used by the hook)
|
||||
components/ (optional, subcomponents local to FeatureX)
|
||||
```
|
||||
|
||||
### Example: Page with nested components
|
||||
|
||||
```tsx
|
||||
// Page composition
|
||||
app/(platform)/dashboard/
|
||||
page.tsx
|
||||
useDashboardPage.ts
|
||||
components/ # (Sub-components the dashboard page is made of)
|
||||
StatsPanel/
|
||||
StatsPanel.tsx
|
||||
useStatsPanel.ts
|
||||
helpers.ts
|
||||
components/ # (Sub-components belonging to StatsPanel)
|
||||
StatCard/
|
||||
StatCard.tsx
|
||||
ActivityFeed/
|
||||
ActivityFeed.tsx
|
||||
useActivityFeed.ts
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Prefer function declarations for components and handlers
|
||||
- Only use arrow functions for small inline lambdas (e.g., in `map`)
|
||||
- Avoid barrel files and `index.ts` re-exports
|
||||
- Keep component files focused and readable; push complex logic to `helpers.ts`
|
||||
- Abstract reusable, cross-feature logic into `src/services/` or `src/lib/utils.ts` as appropriate
|
||||
- Build components encapsulated so they can be easily reused and abstracted elsewhere
|
||||
- Nest sub-components within a `components/` folder when they're local to the parent feature
|
||||
|
||||
### Exceptions
|
||||
|
||||
When to simplify the structure:
|
||||
|
||||
**Small hook logic (3-4 lines)**
|
||||
|
||||
If the hook logic is minimal, keep it inline with the render function:
|
||||
|
||||
```tsx
|
||||
export function ActivityAlert() {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<Alert onClose={() => setIsVisible(false)}>New activity detected</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Render-only components**
|
||||
|
||||
Components with no hook logic can be direct files in `components/` without a folder:
|
||||
|
||||
```
|
||||
components/
|
||||
ActivityAlert.tsx (render-only, no folder needed)
|
||||
StatsPanel/ (has hook logic, needs folder)
|
||||
StatsPanel.tsx
|
||||
useStatsPanel.ts
|
||||
```
|
||||
|
||||
### Hook file structure
|
||||
|
||||
When separating logic into a custom hook:
|
||||
|
||||
```tsx
|
||||
// useStatsPanel.ts
|
||||
export function useStatsPanel() {
|
||||
const [data, setData] = useState<Stats[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats().then(setData);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
refresh: () => fetchStats().then(setData),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Always return an object** that exposes data and methods to the view
|
||||
- **Export a single function** named after the component (e.g., `useStatsPanel` for `StatsPanel.tsx`)
|
||||
- **Abstract into helpers.ts** when hook logic grows large, so the hook file remains readable by scanning without diving into implementation details
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Data fetching patterns
|
||||
|
||||
All API hooks are generated from the backend OpenAPI specification using [Orval](https://orval.dev/). The hooks are type-safe and follow the operation names defined in the backend API.
|
||||
|
||||
### How to discover hooks
|
||||
|
||||
Most of the time you can rely on auto-import by typing the endpoint or operation name. Your IDE will suggest the generated hooks based on the OpenAPI operation IDs.
|
||||
|
||||
**Examples of hook naming patterns:**
|
||||
|
||||
- `GET /api/v1/notifications` → `useGetV1GetNotificationPreferences`
|
||||
- `POST /api/v2/store/agents` → `usePostV2CreateStoreAgent`
|
||||
- `DELETE /api/v2/store/submissions/{id}` → `useDeleteV2DeleteStoreSubmission`
|
||||
- `GET /api/v2/library/agents` → `useGetV2ListLibraryAgents`
|
||||
|
||||
**Pattern**: `use{Method}{Version}{OperationName}`
|
||||
|
||||
You can also explore the generated hooks by browsing `src/app/api/__generated__/endpoints/` which is organized by API tags (e.g., `auth`, `store`, `library`).
|
||||
|
||||
**OpenAPI specs:**
|
||||
|
||||
- Production: [https://backend.agpt.co/openapi.json](https://backend.agpt.co/openapi.json)
|
||||
- Staging: [https://dev-server.agpt.co/openapi.json](https://dev-server.agpt.co/openapi.json)
|
||||
|
||||
### Generated hooks (client)
|
||||
|
||||
Prefer the generated React Query hooks (via Orval + React Query):
|
||||
|
||||
```tsx
|
||||
import { useGetV1GetNotificationPreferences } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const { data, isLoading, isError } = useGetV1GetNotificationPreferences({
|
||||
query: {
|
||||
select: (res) => res.data,
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
if (isError) throw new Error("Failed to load preferences");
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}
|
||||
```
|
||||
|
||||
### Generated mutations (client)
|
||||
|
||||
```tsx
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useDeleteV2DeleteStoreSubmission,
|
||||
getGetV2ListMySubmissionsQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
|
||||
export function DeleteSubmissionButton({
|
||||
submissionId,
|
||||
}: {
|
||||
submissionId: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: deleteSubmission, isPending } =
|
||||
useDeleteV2DeleteStoreSubmission({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListMySubmissionsQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function onClick() {
|
||||
await deleteSubmission({ submissionId });
|
||||
}
|
||||
|
||||
return (
|
||||
<button disabled={isPending} onClick={onClick}>
|
||||
Delete
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side prefetch + client hydration
|
||||
|
||||
Use server-side prefetch to improve TTFB while keeping the component tree client-first (see [React Query SSR & Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr)):
|
||||
|
||||
```tsx
|
||||
// in a server component
|
||||
import { getQueryClient } from "@/lib/tanstack-query/getQueryClient";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import {
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
prefetchGetV2ListStoreCreatorsQuery,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
|
||||
export default async function MarketplacePage() {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
await Promise.all([
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, { featured: true }),
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, { sorted_by: "runs" }),
|
||||
prefetchGetV2ListStoreCreatorsQuery(queryClient, {
|
||||
featured: true,
|
||||
sorted_by: "num_agents",
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{/* Client component tree goes here */}
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Do not introduce new usages of `BackendAPI` or `src/lib/autogpt-server-api/*`
|
||||
- Keep transformations and mapping logic close to the consumer (hook), not in the view
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Error handling
|
||||
|
||||
The app has multiple error handling strategies depending on the type of error:
|
||||
|
||||
### Render/runtime errors
|
||||
|
||||
Use `<ErrorCard />` to display render or runtime errors gracefully:
|
||||
|
||||
```tsx
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard";
|
||||
|
||||
export function DataPanel() {
|
||||
const { data, isLoading, isError, error } = useGetData();
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (isError) return <ErrorCard error={error} />;
|
||||
|
||||
return <div>{data.content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### API mutation errors
|
||||
|
||||
Display mutation errors using toast notifications:
|
||||
|
||||
```tsx
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateSettings } = useUpdateSettingsMutation({
|
||||
mutation: {
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to update settings",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { updateSettings };
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Sentry capture
|
||||
|
||||
When needed, you can manually capture exceptions to Sentry:
|
||||
|
||||
```tsx
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
try {
|
||||
await riskyOperation();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, {
|
||||
tags: { context: "feature-x" },
|
||||
extra: { metadata: additionalData },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
### Global error boundaries
|
||||
|
||||
The app has error boundaries already configured to:
|
||||
|
||||
- Capture uncaught errors globally and send them to Sentry
|
||||
- Display a user-friendly error UI when something breaks
|
||||
- Prevent the entire app from crashing
|
||||
|
||||
You don't need to wrap components in error boundaries manually unless you need custom error recovery logic.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 Feature Flags
|
||||
|
||||
- Flags are powered by [LaunchDarkly](https://docs.launchdarkly.com/)
|
||||
- Use the helper APIs under `src/services/feature-flags`
|
||||
|
||||
Check a flag in a client component:
|
||||
|
||||
```tsx
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
export function AgentActivityPanel() {
|
||||
const enabled = useGetFlag(Flag.AGENT_ACTIVITY);
|
||||
if (!enabled) return null;
|
||||
return <div>Feature is enabled!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Protect a route or page component:
|
||||
|
||||
```tsx
|
||||
import { withFeatureFlag } from "@/services/feature-flags/with-feature-flag";
|
||||
|
||||
export const MyFeaturePage = withFeatureFlag(function Page() {
|
||||
return <div>My feature page</div>;
|
||||
}, "my-feature-flag");
|
||||
```
|
||||
|
||||
Local dev and Playwright:
|
||||
|
||||
- Set `NEXT_PUBLIC_PW_TEST=true` to use mocked flag values during local development and tests
|
||||
|
||||
Adding new flags:
|
||||
|
||||
1. Add the flag to the `Flag` enum and `FlagValues` type
|
||||
2. Provide a mock value in the mock map
|
||||
3. Configure the flag in LaunchDarkly
|
||||
|
||||
---
|
||||
|
||||
## 📙 Naming conventions
|
||||
|
||||
General:
|
||||
|
||||
- Variables and functions should read like plain English
|
||||
- Prefer `const` over `let` unless reassignment is required
|
||||
- Use searchable constants instead of magic numbers
|
||||
|
||||
Files:
|
||||
|
||||
- Components and hooks: `PascalCase` for component files, `camelCase` for hooks
|
||||
- Other files: `kebab-case`
|
||||
- Do not create barrel files or `index.ts` re-exports
|
||||
|
||||
Types:
|
||||
|
||||
- Prefer `interface` for object shapes
|
||||
- Component props should be `interface Props { ... }`
|
||||
- Use precise types; avoid `any` and unsafe casts
|
||||
|
||||
Parameters:
|
||||
|
||||
- If more than one parameter is needed, pass a single `Args` object for clarity
|
||||
|
||||
Comments:
|
||||
|
||||
- Keep comments minimal; code should be clear by itself
|
||||
- Only document non-obvious intent, invariants, or caveats
|
||||
|
||||
Functions:
|
||||
|
||||
- Prefer function declarations for components and handlers
|
||||
- Only use arrow functions for small inline callbacks
|
||||
|
||||
Control flow:
|
||||
|
||||
- Use early returns to reduce nesting
|
||||
- Avoid catching errors unless you handle them meaningfully
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Styling
|
||||
|
||||
- Use Tailwind utilities; prefer semantic, composable class names
|
||||
- Use shadcn/ui components as building blocks when available
|
||||
- Use the `tailwind-scrollbar` utilities for scrollbar styling
|
||||
- Keep responsive and dark-mode behavior consistent with the design system
|
||||
|
||||
Additional requirements:
|
||||
|
||||
- Do not import shadcn primitives directly in feature code; only use components exposed in our design system under `src/components`. shadcn is a low-level skeleton we style on top of and is not meant to be consumed directly.
|
||||
- Prefer design tokens over Tailwind's default theme whenever possible (e.g., color, spacing, radius, and typography tokens). Avoid hardcoded values and default palette if a token exists.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Errors and ⏳ Loading
|
||||
|
||||
- **Errors**: Use the `ErrorCard` component from the design system to display API/HTTP errors and retry actions. Keep error derivation/mapping in hooks; pass the final message to the component.
|
||||
- Component: `src/components/molecules/ErrorCard/ErrorCard.tsx`
|
||||
- **Loading**: Use the `Skeleton` component(s) from the design system for loading states. Favor domain-appropriate skeleton layouts (lists, cards, tables) over spinners.
|
||||
- See Storybook examples under Atoms/Skeleton for patterns.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Responsive and mobile-first
|
||||
|
||||
- Build mobile-first. Ensure new UI looks great from a 375px viewport width (iPhone SE) upwards.
|
||||
- Validate layouts at common breakpoints (375, 768, 1024, 1280). Prefer stacking and progressive disclosure on small screens.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 State for complex flows
|
||||
|
||||
For components/flows with complex state, multi-step wizards, or cross-component coordination, prefer a small co-located store using [Zustand](https://github.com/pmndrs/zustand).
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Co-locate the store with the feature (e.g., `FeatureX/store.ts`).
|
||||
- Expose typed selectors to minimize re-renders.
|
||||
- Keep effects and API calls in hooks; stores hold state and pure actions.
|
||||
|
||||
Example: simple store with selectors
|
||||
|
||||
```ts
|
||||
import { create } from "zustand";
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
data: Record<string, unknown>;
|
||||
next(): void;
|
||||
back(): void;
|
||||
setField(args: { key: string; value: unknown }): void;
|
||||
}
|
||||
|
||||
export const useWizardStore = create<WizardState>((set) => ({
|
||||
step: 0,
|
||||
data: {},
|
||||
next() {
|
||||
set((state) => ({ step: state.step + 1 }));
|
||||
},
|
||||
back() {
|
||||
set((state) => ({ step: Math.max(0, state.step - 1) }));
|
||||
},
|
||||
setField({ key, value }) {
|
||||
set((state) => ({ data: { ...state.data, [key]: value } }));
|
||||
},
|
||||
}));
|
||||
|
||||
// Usage in a component (selectors keep updates scoped)
|
||||
function WizardFooter() {
|
||||
const step = useWizardStore((s) => s.step);
|
||||
const next = useWizardStore((s) => s.next);
|
||||
const back = useWizardStore((s) => s.back);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={back} disabled={step === 0}>Back</button>
|
||||
<button onClick={next}>Next</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Example: async action coordinated via hook + store
|
||||
|
||||
```ts
|
||||
// FeatureX/useFeatureX.ts
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useWizardStore } from "./store";
|
||||
|
||||
export function useFeatureX() {
|
||||
const setField = useWizardStore((s) => s.setField);
|
||||
const next = useWizardStore((s) => s.next);
|
||||
|
||||
const { mutateAsync: save, isPending } = useMutation({
|
||||
mutationFn: async (payload: unknown) => {
|
||||
// call API here
|
||||
return payload;
|
||||
},
|
||||
onSuccess(data) {
|
||||
setField({ key: "result", value: data });
|
||||
next();
|
||||
},
|
||||
});
|
||||
|
||||
return { save, isSaving: isPending };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼 Icons
|
||||
|
||||
- Only use Phosphor Icons. Treat all other icon libraries as deprecated for new code.
|
||||
- Package: `@phosphor-icons/react`
|
||||
- Site: [`https://phosphoricons.com/`](https://phosphoricons.com/)
|
||||
|
||||
Example usage:
|
||||
|
||||
```tsx
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
|
||||
export function CreateButton() {
|
||||
return (
|
||||
<button type="button" className="inline-flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
Create
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & Storybook
|
||||
|
||||
- End-to-end: [Playwright](https://playwright.dev/docs/intro) (`pnpm test`, `pnpm test-ui`)
|
||||
- [Storybook](https://storybook.js.org/docs) for isolated UI development (`pnpm storybook` / `pnpm build-storybook`)
|
||||
- For Storybook tests in CI, see [`@storybook/test-runner`](https://storybook.js.org/docs/writing-tests/test-runner) (`test-storybook:ci`)
|
||||
- When changing components in `src/components`, update or add stories and visually verify in Storybook/Chromatic
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tooling & Scripts
|
||||
|
||||
Common scripts (see `package.json` for full list):
|
||||
|
||||
- `pnpm dev` — Start Next.js dev server (generates API client first)
|
||||
- `pnpm build` — Build for production
|
||||
- `pnpm start` — Start production server
|
||||
- `pnpm lint` — ESLint + Prettier check
|
||||
- `pnpm format` — Format code
|
||||
- `pnpm types` — Type-check
|
||||
- `pnpm storybook` — Run Storybook
|
||||
- `pnpm test` — Run Playwright tests
|
||||
|
||||
Generated API client:
|
||||
|
||||
- `pnpm generate:api` — Fetch OpenAPI spec and regenerate the client
|
||||
|
||||
---
|
||||
|
||||
## ✅ PR checklist (Frontend)
|
||||
|
||||
- Client-first: server components only for SEO or extreme TTFB needs
|
||||
- Uses generated API hooks; no new `BackendAPI` usages
|
||||
- UI uses `src/components` primitives; no new `_legacy__` components
|
||||
- Logic is separated into `use*.ts` and `helpers.ts` when non-trivial
|
||||
- Reusable logic extracted to `src/services/` or `src/lib/utils.ts` when appropriate
|
||||
- Navigation uses the Next.js router
|
||||
- Lint, format, type-check, and tests pass locally
|
||||
- Stories updated/added if UI changed; verified in Storybook
|
||||
|
||||
---
|
||||
|
||||
## ♻️ Migration guidance
|
||||
|
||||
When touching legacy code:
|
||||
|
||||
- Replace usages of `src/components/_legacy__/*` with the modern design system components under `src/components`
|
||||
- Replace `BackendAPI` or `src/lib/autogpt-server-api/*` with generated API hooks
|
||||
- Move presentational logic into render files and data/behavior into hooks
|
||||
- Keep one-off transformations in local `helpers.ts`; move reusable logic to `src/services/` or `src/lib/utils.ts`
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- Design system (Chromatic): [`https://dev--670f94474adee5e32c896b98.chromatic.com/`](https://dev--670f94474adee5e32c896b98.chromatic.com/)
|
||||
- Project README for setup and API client examples: `autogpt_platform/frontend/README.md`
|
||||
- Conventional Commits: [conventionalcommits.org](https://www.conventionalcommits.org/)
|
||||
@@ -4,20 +4,12 @@ This is the frontend for AutoGPT's next generation
|
||||
|
||||
This project uses [**pnpm**](https://pnpm.io/) as the package manager via **corepack**. [Corepack](https://github.com/nodejs/corepack) is a Node.js tool that automatically manages package managers without requiring global installations.
|
||||
|
||||
For architecture, conventions, data fetching, feature flags, design system usage, state management, and PR process, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure you have Node.js 16.10+ installed. Corepack is included with Node.js by default.
|
||||
|
||||
### ⚠️ Migrating from yarn
|
||||
|
||||
> This project was previously using yarn1, make sure to clean up the old files if you set it up previously with yarn:
|
||||
>
|
||||
> ```bash
|
||||
> rm -f yarn.lock && rm -rf node_modules
|
||||
> ```
|
||||
>
|
||||
> Then follow the setup steps below.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. **Enable corepack** (run this once on your system):
|
||||
@@ -96,184 +88,13 @@ Every time a new Front-end dependency is added by you or others, you will need t
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## 🔄 Data Fetching Strategy
|
||||
## 🔄 Data Fetching
|
||||
|
||||
> [!NOTE]
|
||||
> You don't need to run the OpenAPI commands below to run the Front-end. You will only need to run them when adding or modifying endpoints on the Backend API and wanting to use those on the Frontend.
|
||||
|
||||
This project uses an auto-generated API client powered by [**Orval**](https://orval.dev/), which creates type-safe API clients from OpenAPI specifications.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Backend Requirements**: Each API endpoint needs a summary and tag in the OpenAPI spec
|
||||
2. **Operation ID Generation**: FastAPI generates operation IDs using the pattern `{method}{tag}{summary}`
|
||||
3. **Spec Fetching**: The OpenAPI spec is fetched from `http://localhost:8006/openapi.json` and saved to the frontend
|
||||
4. **Spec Transformation**: The OpenAPI spec is cleaned up using a custom transformer (see `autogpt_platform/frontend/src/app/api/transformers`)
|
||||
5. **Client Generation**: Auto-generated client includes TypeScript types, API endpoints, and Zod schemas, organized by tags
|
||||
|
||||
### API Client Commands
|
||||
|
||||
```bash
|
||||
# Fetch OpenAPI spec from backend and generate client
|
||||
pnpm generate:api
|
||||
|
||||
# Only fetch the OpenAPI spec
|
||||
pnpm fetch:openapi
|
||||
|
||||
# Only generate the client (after spec is fetched)
|
||||
pnpm generate:api-client
|
||||
```
|
||||
|
||||
### Using the Generated Client
|
||||
|
||||
The generated client provides React Query hooks for both queries and mutations:
|
||||
|
||||
#### Queries (GET requests)
|
||||
|
||||
```typescript
|
||||
import { useGetV1GetNotificationPreferences } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
|
||||
const { data, isLoading, isError } = useGetV1GetNotificationPreferences({
|
||||
query: {
|
||||
select: (res) => res.data,
|
||||
// Other React Query options
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Mutations (POST, PUT, DELETE requests)
|
||||
|
||||
```typescript
|
||||
import { useDeleteV2DeleteStoreSubmission } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { getGetV2ListMySubmissionsQueryKey } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: deleteSubmission } = useDeleteV2DeleteStoreSubmission({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
// Invalidate related queries to refresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListMySubmissionsQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
await deleteSubmission({
|
||||
submissionId: submission_id,
|
||||
});
|
||||
```
|
||||
|
||||
#### Server Actions
|
||||
|
||||
For server-side operations, you can also use the generated client functions directly:
|
||||
|
||||
```typescript
|
||||
import { postV1UpdateNotificationPreferences } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
|
||||
// In a server action
|
||||
const preferences = {
|
||||
email: "user@example.com",
|
||||
preferences: {
|
||||
AGENT_RUN: true,
|
||||
ZERO_BALANCE: false,
|
||||
// ... other preferences
|
||||
},
|
||||
daily_limit: 0,
|
||||
};
|
||||
|
||||
await postV1UpdateNotificationPreferences(preferences);
|
||||
```
|
||||
|
||||
#### Server-Side Prefetching
|
||||
|
||||
For server-side components, you can prefetch data on the server and hydrate it in the client cache. This allows immediate access to cached data when queries are called:
|
||||
|
||||
```typescript
|
||||
import { getQueryClient } from "@/lib/tanstack-query/getQueryClient";
|
||||
import {
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
prefetchGetV2ListStoreCreatorsQuery
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
|
||||
// In your server component
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
await Promise.all([
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, {
|
||||
featured: true,
|
||||
}),
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, {
|
||||
sorted_by: "runs",
|
||||
}),
|
||||
prefetchGetV2ListStoreCreatorsQuery(queryClient, {
|
||||
featured: true,
|
||||
sorted_by: "num_agents",
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<MainMarkeplacePage />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
```
|
||||
|
||||
This pattern improves performance by serving pre-fetched data from the server while maintaining the benefits of client-side React Query features.
|
||||
|
||||
### Configuration
|
||||
|
||||
The Orval configuration is located in `autogpt_platform/frontend/orval.config.ts`. It generates two separate clients:
|
||||
|
||||
1. **autogpt_api_client**: React Query hooks for client-side data fetching
|
||||
2. **autogpt_zod_schema**: Zod schemas for validation
|
||||
|
||||
For more details, see the [Orval documentation](https://orval.dev/) or check the configuration file.
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidance on generated API hooks, SSR + hydration patterns, and usage examples. You generally do not need to run OpenAPI commands unless adding/modifying backend endpoints.
|
||||
|
||||
## 🚩 Feature Flags
|
||||
|
||||
This project uses [LaunchDarkly](https://launchdarkly.com/) for feature flags, allowing us to control feature rollouts and A/B testing.
|
||||
|
||||
### Using Feature Flags
|
||||
|
||||
#### Check if a feature is enabled
|
||||
|
||||
```typescript
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
function MyComponent() {
|
||||
const isAgentActivityEnabled = useGetFlag(Flag.AGENT_ACTIVITY);
|
||||
|
||||
if (!isAgentActivityEnabled) {
|
||||
return null; // Hide feature
|
||||
}
|
||||
|
||||
return <div>Feature is enabled!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Protect entire components
|
||||
|
||||
```typescript
|
||||
import { withFeatureFlag } from "@/services/feature-flags/with-feature-flag";
|
||||
|
||||
const MyFeaturePage = withFeatureFlag(MyPageComponent, "my-feature-flag");
|
||||
```
|
||||
|
||||
### Testing with Feature Flags
|
||||
|
||||
For local development or running Playwright tests locally, use mocked feature flags by setting `NEXT_PUBLIC_PW_TEST=true` in your `.env` file. This bypasses LaunchDarkly and uses the mock values defined in the code.
|
||||
|
||||
### Adding New Flags
|
||||
|
||||
1. Add the flag to the `Flag` enum in `use-get-flag.ts`
|
||||
2. Add the flag type to `FlagValues` type
|
||||
3. Add mock value to `mockFlags` for testing
|
||||
4. Configure the flag in LaunchDarkly dashboard
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for feature flag usage patterns, local development with mocks, and how to add new flags.
|
||||
|
||||
## 🚚 Deploy
|
||||
|
||||
@@ -333,7 +154,7 @@ By integrating Storybook into our development workflow, we can streamline UI dev
|
||||
- [**Tailwind CSS**](https://tailwindcss.com/) - Utility-first CSS framework
|
||||
- [**shadcn/ui**](https://ui.shadcn.com/) - Re-usable components built with Radix UI and Tailwind CSS
|
||||
- [**Radix UI**](https://www.radix-ui.com/) - Headless UI components for accessibility
|
||||
- [**Lucide React**](https://lucide.dev/guide/packages/lucide-react) - Beautiful & consistent icons
|
||||
- [**Phosphor Icons**](https://phosphoricons.com/) - Icon set used across the app
|
||||
- [**Framer Motion**](https://motion.dev/) - Animation library for React
|
||||
|
||||
### Development & Testing
|
||||
|
||||
@@ -2,18 +2,11 @@
|
||||
// The config you add here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import {
|
||||
AppEnv,
|
||||
BehaveAs,
|
||||
getAppEnv,
|
||||
getBehaveAs,
|
||||
getEnvironmentStr,
|
||||
} from "@/lib/utils";
|
||||
import { environment } from "@/services/environment";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isProdOrDev = [AppEnv.PROD, AppEnv.DEV].includes(getAppEnv());
|
||||
|
||||
const isCloud = getBehaveAs() === BehaveAs.CLOUD;
|
||||
const isProdOrDev = environment.isProd() || environment.isDev();
|
||||
const isCloud = environment.isCloud();
|
||||
const isDisabled = process.env.DISABLE_SENTRY === "true";
|
||||
|
||||
const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
@@ -21,7 +14,7 @@ const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
Sentry.init({
|
||||
dsn: "https://fe4e4aa4a283391808a5da396da20159@o4505260022104064.ingest.us.sentry.io/4507946746380288",
|
||||
|
||||
environment: getEnvironmentStr(),
|
||||
environment: environment.getEnvironmentStr(),
|
||||
|
||||
enabled: shouldEnable,
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"@sentry/nextjs": "10.15.0",
|
||||
"@supabase/ssr": "0.6.1",
|
||||
"@supabase/supabase-js": "2.55.0",
|
||||
"@tanstack/react-query": "5.85.3",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@types/jaro-winkler": "0.2.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"shepherd.js": "14.5.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwind-scrollbar": "3.1.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
|
||||
144
autogpt_platform/frontend/pnpm-lock.yaml
generated
144
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -99,8 +99,8 @@ importers:
|
||||
specifier: 2.55.0
|
||||
version: 2.55.0
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.85.3
|
||||
version: 5.85.3(react@18.3.1)
|
||||
specifier: 5.87.1
|
||||
version: 5.87.1(react@18.3.1)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -243,8 +243,8 @@ importers:
|
||||
specifier: 2.6.0
|
||||
version: 2.6.0
|
||||
tailwind-scrollbar:
|
||||
specifier: 4.0.2
|
||||
version: 4.0.2(react@18.3.1)(tailwindcss@3.4.17)
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0(tailwindcss@3.4.17)
|
||||
tailwindcss-animate:
|
||||
specifier: 1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
@@ -287,7 +287,7 @@ importers:
|
||||
version: 5.86.0(eslint@8.57.1)(typescript@5.9.2)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: 5.87.3
|
||||
version: 5.87.3(@tanstack/react-query@5.85.3(react@18.3.1))(react@18.3.1)
|
||||
version: 5.87.3(@tanstack/react-query@5.87.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/canvas-confetti':
|
||||
specifier: 1.9.0
|
||||
version: 1.9.0
|
||||
@@ -947,10 +947,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.28.3':
|
||||
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.28.4':
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -985,9 +981,6 @@ packages:
|
||||
'@emnapi/core@1.5.0':
|
||||
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
|
||||
|
||||
'@emnapi/runtime@1.4.5':
|
||||
resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||
|
||||
@@ -1159,12 +1152,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.7.0':
|
||||
resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0':
|
||||
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -2856,8 +2843,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
|
||||
'@tanstack/query-core@5.85.3':
|
||||
resolution: {integrity: sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==}
|
||||
'@tanstack/query-core@5.87.1':
|
||||
resolution: {integrity: sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==}
|
||||
|
||||
'@tanstack/query-devtools@5.87.3':
|
||||
resolution: {integrity: sha512-LkzxzSr2HS1ALHTgDmJH5eGAVsSQiuwz//VhFW5OqNk0OQ+Fsqba0Tsf+NzWRtXYvpgUqwQr4b2zdFZwxHcGvg==}
|
||||
@@ -2868,8 +2855,8 @@ packages:
|
||||
'@tanstack/react-query': ^5.87.1
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.85.3':
|
||||
resolution: {integrity: sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==}
|
||||
'@tanstack/react-query@5.87.1':
|
||||
resolution: {integrity: sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
@@ -3045,9 +3032,6 @@ packages:
|
||||
'@types/phoenix@1.6.6':
|
||||
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
||||
|
||||
'@types/prismjs@1.26.5':
|
||||
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -3740,9 +3724,6 @@ packages:
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
caniuse-lite@1.0.30001735:
|
||||
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
|
||||
|
||||
caniuse-lite@1.0.30001741:
|
||||
resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==}
|
||||
|
||||
@@ -4108,15 +4089,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -6220,11 +6192,6 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prism-react-renderer@2.4.1:
|
||||
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||
peerDependencies:
|
||||
react: '>=16.0.0'
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@@ -6949,11 +6916,11 @@ packages:
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwind-scrollbar@4.0.2:
|
||||
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
|
||||
tailwind-scrollbar@3.1.0:
|
||||
resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
peerDependencies:
|
||||
tailwindcss: 4.x
|
||||
tailwindcss: 3.x
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
@@ -7567,7 +7534,7 @@ snapshots:
|
||||
'@babel/types': 7.28.4
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -7619,7 +7586,7 @@ snapshots:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/helper-compilation-targets': 7.27.2
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
lodash.debounce: 4.0.8
|
||||
resolve: 1.22.10
|
||||
transitivePeerDependencies:
|
||||
@@ -8270,8 +8237,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/runtime@7.28.3': {}
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
@@ -8288,7 +8253,7 @@ snapshots:
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.4
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8325,11 +8290,6 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.4.5':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -8426,11 +8386,6 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)':
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)':
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
@@ -8441,7 +8396,7 @@ snapshots:
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
@@ -8491,7 +8446,7 @@ snapshots:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8592,7 +8547,7 @@ snapshots:
|
||||
|
||||
'@img/sharp-wasm32@0.34.3':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.4.5
|
||||
'@emnapi/runtime': 1.5.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.3':
|
||||
@@ -9041,7 +8996,7 @@ snapshots:
|
||||
ajv: 8.17.1
|
||||
chalk: 4.1.2
|
||||
compare-versions: 6.1.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
esbuild: 0.25.9
|
||||
esutils: 2.0.3
|
||||
fs-extra: 11.3.1
|
||||
@@ -10373,7 +10328,7 @@ snapshots:
|
||||
|
||||
'@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.9.2)(webpack@5.101.3(esbuild@0.25.9))':
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
endent: 2.1.0
|
||||
find-cache-dir: 3.3.2
|
||||
flat-cache: 3.2.0
|
||||
@@ -10460,19 +10415,19 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@tanstack/query-core@5.85.3': {}
|
||||
'@tanstack/query-core@5.87.1': {}
|
||||
|
||||
'@tanstack/query-devtools@5.87.3': {}
|
||||
|
||||
'@tanstack/react-query-devtools@5.87.3(@tanstack/react-query@5.85.3(react@18.3.1))(react@18.3.1)':
|
||||
'@tanstack/react-query-devtools@5.87.3(@tanstack/react-query@5.87.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.87.3
|
||||
'@tanstack/react-query': 5.85.3(react@18.3.1)
|
||||
'@tanstack/react-query': 5.87.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-query@5.85.3(react@18.3.1)':
|
||||
'@tanstack/react-query@5.87.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.85.3
|
||||
'@tanstack/query-core': 5.87.1
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
@@ -10664,8 +10619,6 @@ snapshots:
|
||||
|
||||
'@types/phoenix@1.6.6': {}
|
||||
|
||||
'@types/prismjs@1.26.5': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/react-dom@18.3.5(@types/react@18.3.17)':
|
||||
@@ -10734,7 +10687,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.43.0
|
||||
'@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2)
|
||||
'@typescript-eslint/visitor-keys': 8.43.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
@@ -10744,7 +10697,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2)
|
||||
'@typescript-eslint/types': 8.43.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10763,7 +10716,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.43.0
|
||||
'@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.43.0(eslint@8.57.1)(typescript@5.9.2)
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
@@ -10778,7 +10731,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2)
|
||||
'@typescript-eslint/types': 8.43.0
|
||||
'@typescript-eslint/visitor-keys': 8.43.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
@@ -11395,8 +11348,6 @@ snapshots:
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001735: {}
|
||||
|
||||
caniuse-lite@1.0.30001741: {}
|
||||
|
||||
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
||||
@@ -11598,7 +11549,7 @@ snapshots:
|
||||
dependencies:
|
||||
cipher-base: 1.0.6
|
||||
inherits: 2.0.4
|
||||
ripemd160: 2.0.1
|
||||
ripemd160: 2.0.2
|
||||
sha.js: 2.4.12
|
||||
|
||||
create-hash@1.2.0:
|
||||
@@ -11612,9 +11563,9 @@ snapshots:
|
||||
create-hmac@1.1.7:
|
||||
dependencies:
|
||||
cipher-base: 1.0.6
|
||||
create-hash: 1.1.3
|
||||
create-hash: 1.2.0
|
||||
inherits: 2.0.4
|
||||
ripemd160: 2.0.1
|
||||
ripemd160: 2.0.2
|
||||
safe-buffer: 5.2.1
|
||||
sha.js: 2.4.12
|
||||
|
||||
@@ -11772,10 +11723,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -12077,7 +12024,7 @@ snapshots:
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.9):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
esbuild: 0.25.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12148,7 +12095,7 @@ snapshots:
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.1
|
||||
get-tsconfig: 4.10.1
|
||||
is-bun-module: 2.0.0
|
||||
@@ -12270,7 +12217,7 @@ snapshots:
|
||||
|
||||
eslint@8.57.1:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1)
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1)
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/eslintrc': 2.1.4
|
||||
'@eslint/js': 8.57.1
|
||||
@@ -12281,7 +12228,7 @@ snapshots:
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -13654,7 +13601,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
decode-named-character-reference: 1.2.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -13790,7 +13737,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@next/env': 15.4.7
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001735
|
||||
caniuse-lite: 1.0.30001741
|
||||
postcss: 8.4.31
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
@@ -14311,12 +14258,6 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prism-react-renderer@2.4.1(react@18.3.1):
|
||||
dependencies:
|
||||
'@types/prismjs': 1.26.5
|
||||
clsx: 2.1.1
|
||||
react: 18.3.1
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
@@ -14495,7 +14436,7 @@ snapshots:
|
||||
|
||||
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.3
|
||||
'@babel/runtime': 7.28.4
|
||||
memoize-one: 5.2.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
@@ -14716,7 +14657,7 @@ snapshots:
|
||||
|
||||
require-in-the-middle@7.5.2:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
module-details-from-path: 1.0.4
|
||||
resolve: 1.22.10
|
||||
transitivePeerDependencies:
|
||||
@@ -15259,12 +15200,9 @@ snapshots:
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwind-scrollbar@4.0.2(react@18.3.1)(tailwindcss@3.4.17):
|
||||
tailwind-scrollbar@3.1.0(tailwindcss@3.4.17):
|
||||
dependencies:
|
||||
prism-react-renderer: 2.4.1(react@18.3.1)
|
||||
tailwindcss: 3.4.17
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@3.4.17):
|
||||
dependencies:
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { getAgptServerBaseUrl } from "@/lib/env-config";
|
||||
import { execSync } from "child_process";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
function fetchOpenApiSpec(): void {
|
||||
const args = process.argv.slice(2);
|
||||
const forceFlag = args.includes("--force");
|
||||
|
||||
const baseUrl = getAgptServerBaseUrl();
|
||||
const baseUrl = environment.getAGPTServerBaseUrl();
|
||||
const openApiUrl = `${baseUrl}/openapi.json`;
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
|
||||
@@ -3,18 +3,11 @@
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import { environment } from "@/services/environment";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import {
|
||||
AppEnv,
|
||||
BehaveAs,
|
||||
getAppEnv,
|
||||
getBehaveAs,
|
||||
getEnvironmentStr,
|
||||
} from "./src/lib/utils";
|
||||
|
||||
const isProdOrDev = [AppEnv.PROD, AppEnv.DEV].includes(getAppEnv());
|
||||
|
||||
const isCloud = getBehaveAs() === BehaveAs.CLOUD;
|
||||
const isProdOrDev = environment.isProd() || environment.isDev();
|
||||
const isCloud = environment.isCloud();
|
||||
const isDisabled = process.env.DISABLE_SENTRY === "true";
|
||||
|
||||
const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
@@ -22,7 +15,7 @@ const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
Sentry.init({
|
||||
dsn: "https://fe4e4aa4a283391808a5da396da20159@o4505260022104064.ingest.us.sentry.io/4507946746380288",
|
||||
|
||||
environment: getEnvironmentStr(),
|
||||
environment: environment.getEnvironmentStr(),
|
||||
|
||||
enabled: shouldEnable,
|
||||
|
||||
@@ -40,7 +33,7 @@ Sentry.init({
|
||||
|
||||
enableLogs: true,
|
||||
integrations: [
|
||||
Sentry.captureConsoleIntegration(),
|
||||
Sentry.captureConsoleIntegration({ levels: ["fatal", "error", "warn"] }),
|
||||
Sentry.extraErrorDataIntegration(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,19 +2,12 @@
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import {
|
||||
AppEnv,
|
||||
BehaveAs,
|
||||
getAppEnv,
|
||||
getBehaveAs,
|
||||
getEnvironmentStr,
|
||||
} from "@/lib/utils";
|
||||
import { environment } from "@/services/environment";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
// import { NodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
const isProdOrDev = [AppEnv.PROD, AppEnv.DEV].includes(getAppEnv());
|
||||
|
||||
const isCloud = getBehaveAs() === BehaveAs.CLOUD;
|
||||
const isProdOrDev = environment.isProd() || environment.isDev();
|
||||
const isCloud = environment.isCloud();
|
||||
const isDisabled = process.env.DISABLE_SENTRY === "true";
|
||||
|
||||
const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
@@ -22,7 +15,7 @@ const shouldEnable = !isDisabled && isProdOrDev && isCloud;
|
||||
Sentry.init({
|
||||
dsn: "https://fe4e4aa4a283391808a5da396da20159@o4505260022104064.ingest.us.sentry.io/4507946746380288",
|
||||
|
||||
environment: getEnvironmentStr(),
|
||||
environment: environment.getEnvironmentStr(),
|
||||
|
||||
enabled: shouldEnable,
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import OnboardingAgentCard from "../components/OnboardingAgentCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { StoreAgentDetails } from "@/lib/autogpt-server-api";
|
||||
import { finishOnboarding } from "../6-congrats/actions";
|
||||
import { isEmptyOrWhitespace } from "@/lib/utils";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
import { finishOnboarding } from "../6-congrats/actions";
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState } = useOnboarding(4, "INTEGRATIONS");
|
||||
@@ -24,6 +24,7 @@ export default function Page() {
|
||||
if (agents.length < 2) {
|
||||
finishOnboarding();
|
||||
}
|
||||
|
||||
setAgents(agents);
|
||||
});
|
||||
}, [api, setAgents]);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
import { useState } from "react";
|
||||
import { getSchemaDefaultCredentials } from "../../helpers";
|
||||
import { areAllCredentialsSet, getCredentialFields } from "./helpers";
|
||||
|
||||
type Credential = CredentialsMetaInput | undefined;
|
||||
type Credentials = Record<string, Credential>;
|
||||
|
||||
type Props = {
|
||||
agent: GraphMeta | null;
|
||||
siblingInputs?: Record<string, any>;
|
||||
onCredentialsChange: (
|
||||
credentials: Record<string, CredentialsMetaInput>,
|
||||
) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onLoadingChange: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function AgentOnboardingCredentials(props: Props) {
|
||||
const [inputCredentials, setInputCredentials] = useState<Credentials>({});
|
||||
|
||||
const fields = getCredentialFields(props.agent);
|
||||
const required = Object.keys(fields || {}).length > 0;
|
||||
|
||||
if (!required) return null;
|
||||
|
||||
function handleSelectCredentials(key: string, value: Credential) {
|
||||
const updated = { ...inputCredentials, [key]: value };
|
||||
setInputCredentials(updated);
|
||||
|
||||
const sanitized: Record<string, CredentialsMetaInput> = {};
|
||||
for (const [k, v] of Object.entries(updated)) {
|
||||
if (v) sanitized[k] = v;
|
||||
}
|
||||
|
||||
props.onCredentialsChange(sanitized);
|
||||
|
||||
const isValid = !required || areAllCredentialsSet(fields, updated);
|
||||
props.onValidationChange(isValid);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(fields).map(([key, inputSubSchema]) => (
|
||||
<div key={key} className="mt-4">
|
||||
<CredentialsInput
|
||||
schema={inputSubSchema}
|
||||
selectedCredentials={
|
||||
inputCredentials[key] ??
|
||||
getSchemaDefaultCredentials(inputSubSchema)
|
||||
}
|
||||
onSelectCredentials={(value) => handleSelectCredentials(key, value)}
|
||||
siblingInputs={props.siblingInputs}
|
||||
onLoaded={(loaded) => props.onLoadingChange(!loaded)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export function getCredentialFields(
|
||||
agent: GraphMeta | null,
|
||||
): AgentCredentialsFields {
|
||||
if (!agent) return {};
|
||||
|
||||
const hasNoInputs =
|
||||
!agent.credentials_input_schema ||
|
||||
typeof agent.credentials_input_schema !== "object" ||
|
||||
!("properties" in agent.credentials_input_schema) ||
|
||||
!agent.credentials_input_schema.properties;
|
||||
|
||||
if (hasNoInputs) return {};
|
||||
|
||||
return agent.credentials_input_schema.properties as AgentCredentialsFields;
|
||||
}
|
||||
|
||||
export type AgentCredentialsFields = Record<
|
||||
string,
|
||||
BlockIOCredentialsSubSchema
|
||||
>;
|
||||
|
||||
export function areAllCredentialsSet(
|
||||
fields: AgentCredentialsFields,
|
||||
inputs: Record<string, CredentialsMetaInput | undefined>,
|
||||
) {
|
||||
const required = Object.keys(fields || {});
|
||||
return required.every((k) => Boolean(inputs[k]));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OnboardingText } from "../../components/OnboardingText";
|
||||
|
||||
type RunAgentHintProps = {
|
||||
handleNewRun: () => void;
|
||||
};
|
||||
|
||||
export function RunAgentHint(props: RunAgentHintProps) {
|
||||
return (
|
||||
<div className="ml-[104px] w-[481px] pl-5">
|
||||
<div className="flex flex-col">
|
||||
<OnboardingText variant="header">Run your first agent</OnboardingText>
|
||||
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
|
||||
A 'run' is when your agent starts working on a task
|
||||
</span>
|
||||
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
|
||||
Click on <b>New Run</b> below to try it out
|
||||
</span>
|
||||
|
||||
<div
|
||||
onClick={props.handleNewRun}
|
||||
className={cn(
|
||||
"mt-16 flex h-[68px] w-[330px] items-center justify-center rounded-xl border-2 border-violet-700 bg-neutral-50",
|
||||
"cursor-pointer transition-all duration-200 ease-in-out hover:bg-violet-50",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g stroke="#6d28d9" strokeWidth="1.2" strokeLinecap="round">
|
||||
<line x1="16" y1="8" x2="16" y2="24" />
|
||||
<line x1="8" y1="16" x2="24" y2="16" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="ml-3 font-sans text-[19px] font-medium leading-normal text-violet-700">
|
||||
New run
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
import StarRating from "../../components/StarRating";
|
||||
import SmartImage from "@/components/__legacy__/SmartImage";
|
||||
|
||||
type Props = {
|
||||
storeAgent: StoreAgentDetails | null;
|
||||
};
|
||||
|
||||
export function SelectedAgentCard(props: Props) {
|
||||
return (
|
||||
<div className="fixed left-1/4 top-1/2 w-[481px] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="h-[156px] w-[481px] rounded-xl bg-white px-6 pb-5 pt-4">
|
||||
<span className="font-sans text-xs font-medium tracking-wide text-zinc-500">
|
||||
SELECTED AGENT
|
||||
</span>
|
||||
{props.storeAgent ? (
|
||||
<div className="mt-4 flex h-20 rounded-lg bg-violet-50 p-3">
|
||||
{/* Left image */}
|
||||
<SmartImage
|
||||
src={props.storeAgent.agent_image[0]}
|
||||
alt="Agent cover"
|
||||
className="w-[350px] rounded-lg"
|
||||
/>
|
||||
{/* Right content */}
|
||||
<div className="ml-3 flex flex-1 flex-col">
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<span className="w-[292px] truncate font-sans text-[14px] font-medium leading-tight text-zinc-800">
|
||||
{props.storeAgent.agent_name}
|
||||
</span>
|
||||
<span className="font-norma w-[292px] truncate font-sans text-xs text-zinc-600">
|
||||
by {props.storeAgent.creator}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-[292px] items-center justify-between">
|
||||
<span className="truncate font-sans text-xs font-normal leading-tight text-zinc-600">
|
||||
{props.storeAgent.runs.toLocaleString("en-US")} runs
|
||||
</span>
|
||||
<StarRating
|
||||
className="font-sans text-xs font-normal leading-tight text-zinc-600"
|
||||
starSize={12}
|
||||
rating={props.storeAgent.rating || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex h-20 animate-pulse rounded-lg bg-gray-300 p-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import type {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import type { InputValues } from "./types";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
|
||||
export function computeInitialAgentInputs(
|
||||
agent: GraphMeta | null,
|
||||
@@ -21,7 +21,6 @@ export function computeInitialAgentInputs(
|
||||
result[key] = existingInputs[key];
|
||||
return;
|
||||
}
|
||||
// GraphIOSubSchema.default is typed as string, but server may return other primitives
|
||||
const def = (subSchema as unknown as { default?: string | number }).default;
|
||||
result[key] = def ?? "";
|
||||
});
|
||||
@@ -29,40 +28,20 @@ export function computeInitialAgentInputs(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getAgentCredentialsInputFields(agent: GraphMeta | null) {
|
||||
const hasNoInputs =
|
||||
!agent?.credentials_input_schema ||
|
||||
typeof agent.credentials_input_schema !== "object" ||
|
||||
!("properties" in agent.credentials_input_schema) ||
|
||||
!agent.credentials_input_schema.properties;
|
||||
|
||||
if (hasNoInputs) return {};
|
||||
|
||||
return agent.credentials_input_schema.properties;
|
||||
}
|
||||
|
||||
export function areAllCredentialsSet(
|
||||
fields: Record<string, BlockIOCredentialsSubSchema>,
|
||||
inputs: Record<string, CredentialsMetaInput | undefined>,
|
||||
) {
|
||||
const required = Object.keys(fields || {});
|
||||
return required.every((k) => Boolean(inputs[k]));
|
||||
}
|
||||
|
||||
type IsRunDisabledParams = {
|
||||
agent: GraphMeta | null;
|
||||
isRunning: boolean;
|
||||
agentInputs: InputValues | null | undefined;
|
||||
credentialsRequired: boolean;
|
||||
credentialsSatisfied: boolean;
|
||||
credentialsValid: boolean;
|
||||
credentialsLoaded: boolean;
|
||||
};
|
||||
|
||||
export function isRunDisabled({
|
||||
agent,
|
||||
isRunning,
|
||||
agentInputs,
|
||||
credentialsRequired,
|
||||
credentialsSatisfied,
|
||||
credentialsValid,
|
||||
credentialsLoaded,
|
||||
}: IsRunDisabledParams) {
|
||||
const hasEmptyInput = Object.values(agentInputs || {}).some(
|
||||
(value) => String(value).trim() === "",
|
||||
@@ -71,7 +50,8 @@ export function isRunDisabled({
|
||||
if (hasEmptyInput) return true;
|
||||
if (!agent) return true;
|
||||
if (isRunning) return true;
|
||||
if (credentialsRequired && !credentialsSatisfied) return true;
|
||||
if (!credentialsValid) return true;
|
||||
if (!credentialsLoaded) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -81,13 +61,3 @@ export function getSchemaDefaultCredentials(
|
||||
): CredentialsMetaInput | undefined {
|
||||
return schema.default as CredentialsMetaInput | undefined;
|
||||
}
|
||||
|
||||
export function sanitizeCredentials(
|
||||
map: Record<string, CredentialsMetaInput | undefined>,
|
||||
): Record<string, CredentialsMetaInput> {
|
||||
const sanitized: Record<string, CredentialsMetaInput> = {};
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (value) sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -1,224 +1,66 @@
|
||||
"use client";
|
||||
import SmartImage from "@/components/__legacy__/SmartImage";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import { OnboardingHeader, OnboardingStep } from "../components/OnboardingStep";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import StarRating from "../components/StarRating";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { GraphMeta, StoreAgentDetails } from "@/lib/autogpt-server-api";
|
||||
import type { InputValues } from "./types";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
areAllCredentialsSet,
|
||||
computeInitialAgentInputs,
|
||||
getAgentCredentialsInputFields,
|
||||
isRunDisabled,
|
||||
getSchemaDefaultCredentials,
|
||||
sanitizeCredentials,
|
||||
} from "./helpers";
|
||||
import { isRunDisabled } from "./helpers";
|
||||
import { useOnboardingRunStep } from "./useOnboardingRunStep";
|
||||
import { RunAgentHint } from "./components/RunAgentHint";
|
||||
import { SelectedAgentCard } from "./components/SelectedAgentCard";
|
||||
import { AgentOnboardingCredentials } from "./components/AgentOnboardingCredentials/AgentOnboardingCredentials";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState, setStep } = useOnboarding(
|
||||
undefined,
|
||||
"AGENT_CHOICE",
|
||||
);
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [agent, setAgent] = useState<GraphMeta | null>(null);
|
||||
const [storeAgent, setStoreAgent] = useState<StoreAgentDetails | null>(null);
|
||||
const [runningAgent, setRunningAgent] = useState(false);
|
||||
const [inputCredentials, setInputCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput | undefined>
|
||||
>({});
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
const {
|
||||
ready,
|
||||
error,
|
||||
showInput,
|
||||
agent,
|
||||
onboarding,
|
||||
storeAgent,
|
||||
runningAgent,
|
||||
credentialsValid,
|
||||
credentialsLoaded,
|
||||
handleSetAgentInput,
|
||||
handleRunAgent,
|
||||
handleNewRun,
|
||||
handleCredentialsChange,
|
||||
handleCredentialsValidationChange,
|
||||
handleCredentialsLoadingChange,
|
||||
} = useOnboardingRunStep();
|
||||
|
||||
useEffect(() => {
|
||||
setStep(5);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.selectedStoreListingVersionId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.getStoreAgentByVersionId(state?.selectedStoreListingVersionId)
|
||||
.then((storeAgent) => {
|
||||
setStoreAgent(storeAgent);
|
||||
});
|
||||
api
|
||||
.getGraphMetaByStoreListingVersionID(state.selectedStoreListingVersionId)
|
||||
.then((meta) => {
|
||||
setAgent(meta);
|
||||
const update = computeInitialAgentInputs(
|
||||
meta,
|
||||
(state.agentInput as unknown as InputValues) || null,
|
||||
);
|
||||
updateState({ agentInput: update });
|
||||
});
|
||||
}, [api, setAgent, updateState, state?.selectedStoreListingVersionId]);
|
||||
|
||||
const agentCredentialsInputFields = getAgentCredentialsInputFields(agent);
|
||||
|
||||
const credentialsRequired =
|
||||
Object.keys(agentCredentialsInputFields || {}).length > 0;
|
||||
|
||||
const allCredentialsAreSet = areAllCredentialsSet(
|
||||
agentCredentialsInputFields,
|
||||
inputCredentials,
|
||||
);
|
||||
|
||||
function setAgentInput(key: string, value: string) {
|
||||
updateState({
|
||||
agentInput: {
|
||||
...state?.agentInput,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
return <ErrorCard responseError={error} />;
|
||||
}
|
||||
|
||||
async function runAgent() {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
setRunningAgent(true);
|
||||
try {
|
||||
const libraryAgent = await api.addMarketplaceAgentToLibrary(
|
||||
storeAgent?.store_listing_version_id || "",
|
||||
);
|
||||
const { id: runID } = await api.executeGraph(
|
||||
libraryAgent.graph_id,
|
||||
libraryAgent.graph_version,
|
||||
state?.agentInput || {},
|
||||
sanitizeCredentials(inputCredentials),
|
||||
);
|
||||
updateState({
|
||||
onboardingAgentExecutionId: runID,
|
||||
agentRuns: (state?.agentRuns || 0) + 1,
|
||||
});
|
||||
router.push("/onboarding/6-congrats");
|
||||
} catch (error) {
|
||||
console.error("Error running agent:", error);
|
||||
toast({
|
||||
title: "Error running agent",
|
||||
description:
|
||||
"There was an error running your agent. Please try again or try choosing a different agent if it still fails.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setRunningAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
const runYourAgent = (
|
||||
<div className="ml-[104px] w-[481px] pl-5">
|
||||
<div className="flex flex-col">
|
||||
<OnboardingText variant="header">Run your first agent</OnboardingText>
|
||||
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
|
||||
A 'run' is when your agent starts working on a task
|
||||
</span>
|
||||
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
|
||||
Click on <b>New Run</b> below to try it out
|
||||
</span>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
setShowInput(true);
|
||||
setStep(6);
|
||||
updateState({
|
||||
completedSteps: [
|
||||
...(state?.completedSteps || []),
|
||||
"AGENT_NEW_RUN",
|
||||
],
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"mt-16 flex h-[68px] w-[330px] items-center justify-center rounded-xl border-2 border-violet-700 bg-neutral-50",
|
||||
"cursor-pointer transition-all duration-200 ease-in-out hover:bg-violet-50",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g stroke="#6d28d9" strokeWidth="1.2" strokeLinecap="round">
|
||||
<line x1="16" y1="8" x2="16" y2="24" />
|
||||
<line x1="8" y1="16" x2="24" y2="16" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="ml-3 font-sans text-[19px] font-medium leading-normal text-violet-700">
|
||||
New run
|
||||
</span>
|
||||
</div>
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep dotted>
|
||||
<OnboardingHeader backHref={"/onboarding/4-agent"} transparent />
|
||||
{/* Agent card */}
|
||||
<div className="fixed left-1/4 top-1/2 w-[481px] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="h-[156px] w-[481px] rounded-xl bg-white px-6 pb-5 pt-4">
|
||||
<span className="font-sans text-xs font-medium tracking-wide text-zinc-500">
|
||||
SELECTED AGENT
|
||||
</span>
|
||||
{storeAgent ? (
|
||||
<div className="mt-4 flex h-20 rounded-lg bg-violet-50 p-2">
|
||||
{/* Left image */}
|
||||
<SmartImage
|
||||
src={storeAgent?.agent_image[0]}
|
||||
alt="Agent cover"
|
||||
imageContain
|
||||
className="w-[350px] rounded-lg"
|
||||
/>
|
||||
{/* Right content */}
|
||||
<div className="ml-2 flex flex-1 flex-col">
|
||||
<span className="w-[292px] truncate font-sans text-[14px] font-medium leading-normal text-zinc-800">
|
||||
{storeAgent?.agent_name}
|
||||
</span>
|
||||
<span className="mt-[5px] w-[292px] truncate font-sans text-xs font-normal leading-tight text-zinc-600">
|
||||
by {storeAgent?.creator}
|
||||
</span>
|
||||
<div className="mt-auto flex w-[292px] justify-between">
|
||||
<span className="mt-1 truncate font-sans text-xs font-normal leading-tight text-zinc-600">
|
||||
{storeAgent?.runs.toLocaleString("en-US")} runs
|
||||
</span>
|
||||
<StarRating
|
||||
className="font-sans text-xs font-normal leading-tight text-zinc-600"
|
||||
starSize={12}
|
||||
rating={storeAgent?.rating || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex h-20 animate-pulse rounded-lg bg-gray-300 p-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[80vh] items-center justify-center">
|
||||
{/* Left side */}
|
||||
<SelectedAgentCard storeAgent={storeAgent} />
|
||||
<div className="w-[481px]" />
|
||||
{/* Right side */}
|
||||
{!showInput ? (
|
||||
runYourAgent
|
||||
<RunAgentHint handleNewRun={handleNewRun} />
|
||||
) : (
|
||||
<div className="ml-[104px] w-[481px] pl-5">
|
||||
<div className="flex flex-col">
|
||||
@@ -232,30 +74,7 @@ export default function Page() {
|
||||
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
|
||||
When you're done, click <b>Run Agent</b>.
|
||||
</span>
|
||||
{Object.entries(agentCredentialsInputFields || {}).map(
|
||||
([key, inputSubSchema]) => (
|
||||
<div key={key} className="mt-4">
|
||||
<CredentialsInput
|
||||
schema={inputSubSchema}
|
||||
selectedCredentials={
|
||||
inputCredentials[key] ??
|
||||
getSchemaDefaultCredentials(inputSubSchema)
|
||||
}
|
||||
onSelectCredentials={(value) =>
|
||||
setInputCredentials((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
siblingInputs={
|
||||
(state?.agentInput || undefined) as
|
||||
| Record<string, any>
|
||||
| undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<Card className="agpt-box mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
@@ -272,13 +91,23 @@ export default function Page() {
|
||||
</label>
|
||||
<RunAgentInputs
|
||||
schema={inputSubSchema}
|
||||
value={state?.agentInput?.[key]}
|
||||
value={onboarding.state?.agentInput?.[key]}
|
||||
placeholder={inputSubSchema.description}
|
||||
onChange={(value) => setAgentInput(key, value)}
|
||||
onChange={(value) => handleSetAgentInput(key, value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<AgentOnboardingCredentials
|
||||
agent={agent}
|
||||
siblingInputs={
|
||||
(onboarding.state?.agentInput as Record<string, any>) ||
|
||||
undefined
|
||||
}
|
||||
onCredentialsChange={handleCredentialsChange}
|
||||
onValidationChange={handleCredentialsValidationChange}
|
||||
onLoadingChange={handleCredentialsLoadingChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OnboardingButton
|
||||
@@ -289,11 +118,12 @@ export default function Page() {
|
||||
agent,
|
||||
isRunning: runningAgent,
|
||||
agentInputs:
|
||||
(state?.agentInput as unknown as InputValues) || null,
|
||||
credentialsRequired,
|
||||
credentialsSatisfied: allCredentialsAreSet,
|
||||
(onboarding.state?.agentInput as unknown as InputValues) ||
|
||||
null,
|
||||
credentialsValid,
|
||||
credentialsLoaded,
|
||||
})}
|
||||
onClick={runAgent}
|
||||
onClick={handleRunAgent}
|
||||
icon={<Play className="mr-2" size={18} />}
|
||||
>
|
||||
Run agent
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { computeInitialAgentInputs } from "./helpers";
|
||||
import { InputValues } from "./types";
|
||||
import {
|
||||
useGetV2GetAgentByVersion,
|
||||
useGetV2GetAgentGraph,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
|
||||
export function useOnboardingRunStep() {
|
||||
const onboarding = useOnboarding(undefined, "AGENT_CHOICE");
|
||||
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [agent, setAgent] = useState<GraphMeta | null>(null);
|
||||
const [storeAgent, setStoreAgent] = useState<StoreAgentDetails | null>(null);
|
||||
const [runningAgent, setRunningAgent] = useState(false);
|
||||
|
||||
const [inputCredentials, setInputCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
const [credentialsValid, setCredentialsValid] = useState(true);
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
const currentAgentVersion =
|
||||
onboarding.state?.selectedStoreListingVersionId ?? "";
|
||||
|
||||
const storeAgentQuery = useGetV2GetAgentByVersion(currentAgentVersion, {
|
||||
query: { enabled: !!currentAgentVersion },
|
||||
});
|
||||
|
||||
const graphMetaQuery = useGetV2GetAgentGraph(currentAgentVersion, {
|
||||
query: { enabled: !!currentAgentVersion },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onboarding.setStep(5);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (storeAgentQuery.data && storeAgentQuery.data.status === 200) {
|
||||
setStoreAgent(storeAgentQuery.data.data);
|
||||
}
|
||||
}, [storeAgentQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphMetaQuery.data &&
|
||||
graphMetaQuery.data.status === 200 &&
|
||||
onboarding.state
|
||||
) {
|
||||
const graphMeta = graphMetaQuery.data.data as GraphMeta;
|
||||
|
||||
setAgent(graphMeta);
|
||||
|
||||
const update = computeInitialAgentInputs(
|
||||
graphMeta,
|
||||
(onboarding.state.agentInput as unknown as InputValues) || null,
|
||||
);
|
||||
|
||||
onboarding.updateState({ agentInput: update });
|
||||
}
|
||||
}, [graphMetaQuery.data]);
|
||||
|
||||
function handleNewRun() {
|
||||
if (!onboarding.state) return;
|
||||
|
||||
setShowInput(true);
|
||||
onboarding.setStep(6);
|
||||
onboarding.updateState({
|
||||
completedSteps: [
|
||||
...(onboarding.state.completedSteps || []),
|
||||
"AGENT_NEW_RUN",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleSetAgentInput(key: string, value: string) {
|
||||
if (!onboarding.state) return;
|
||||
|
||||
onboarding.updateState({
|
||||
agentInput: {
|
||||
...onboarding.state.agentInput,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunAgent() {
|
||||
if (!agent || !storeAgent || !onboarding.state) {
|
||||
toast({
|
||||
title: "Error getting agent",
|
||||
description:
|
||||
"Either the agent is not available or there was an error getting it.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningAgent(true);
|
||||
|
||||
try {
|
||||
const libraryAgent = await api.addMarketplaceAgentToLibrary(
|
||||
storeAgent?.store_listing_version_id || "",
|
||||
);
|
||||
|
||||
const { id: runID } = await api.executeGraph(
|
||||
libraryAgent.graph_id,
|
||||
libraryAgent.graph_version,
|
||||
onboarding.state.agentInput || {},
|
||||
inputCredentials,
|
||||
);
|
||||
|
||||
onboarding.updateState({
|
||||
onboardingAgentExecutionId: runID,
|
||||
agentRuns: (onboarding.state.agentRuns || 0) + 1,
|
||||
});
|
||||
|
||||
router.push("/onboarding/6-congrats");
|
||||
} catch (error) {
|
||||
console.error("Error running agent:", error);
|
||||
|
||||
toast({
|
||||
title: "Error running agent",
|
||||
description:
|
||||
"There was an error running your agent. Please try again or try choosing a different agent if it still fails.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
setRunningAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: graphMetaQuery.isSuccess && storeAgentQuery.isSuccess,
|
||||
error: graphMetaQuery.error || storeAgentQuery.error,
|
||||
agent,
|
||||
onboarding,
|
||||
showInput,
|
||||
storeAgent,
|
||||
runningAgent,
|
||||
credentialsValid,
|
||||
credentialsLoaded,
|
||||
handleSetAgentInput,
|
||||
handleRunAgent,
|
||||
handleNewRun,
|
||||
handleCredentialsChange: setInputCredentials,
|
||||
handleCredentialsValidationChange: setCredentialsValid,
|
||||
handleCredentialsLoadingChange: (v: boolean) => setCredentialsLoaded(!v),
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function StarRating({
|
||||
)}
|
||||
>
|
||||
{/* Display numerical rating */}
|
||||
<span className="mr-1 mt-1">{roundedRating}</span>
|
||||
<span className="mr-1 mt-0.5">{roundedRating}</span>
|
||||
|
||||
{/* Display stars */}
|
||||
{stars.map((starType, index) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { isServerSide } from "@/lib/utils/is-server-side";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { WaitlistErrorContent } from "@/components/auth/WaitlistErrorContent";
|
||||
import { isWaitlistError } from "@/app/api/auth/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const [errorType, setErrorType] = useState<string | null>(null);
|
||||
@@ -17,7 +17,7 @@ export default function AuthErrorPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// This code only runs on the client side
|
||||
if (!isServerSide()) {
|
||||
if (!environment.isServerSide()) {
|
||||
const hash = window.location.hash.substring(1); // Remove the leading '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
@@ -44,43 +44,31 @@ export default function AuthErrorPage() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<WaitlistErrorContent
|
||||
onClose={() => router.push("/login")}
|
||||
closeButtonText="Back to Login"
|
||||
/>
|
||||
<WaitlistErrorContent onBackToLogin={() => router.push("/login")} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default error display for other types of errors
|
||||
// Use ErrorCard for consistent error display
|
||||
const errorMessage = errorDescription
|
||||
? `${errorDescription}. If this error persists, please contact support at contact@agpt.co`
|
||||
: "An authentication error occurred. Please contact support at contact@agpt.co";
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Text variant="h3">Authentication Error</Text>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
{errorType && (
|
||||
<Text variant="body">
|
||||
<strong>Error Type:</strong> {errorType}
|
||||
</Text>
|
||||
)}
|
||||
{errorCode && (
|
||||
<Text variant="body">
|
||||
<strong>Error Code:</strong> {errorCode}
|
||||
</Text>
|
||||
)}
|
||||
{errorDescription && (
|
||||
<Text variant="body">
|
||||
<strong>Description:</strong> {errorDescription}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => router.push("/login")}>
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<ErrorCard
|
||||
responseError={{
|
||||
message: errorMessage,
|
||||
detail: errorCode
|
||||
? `Error code: ${errorCode}${errorType ? ` (${errorType})` : ""}`
|
||||
: undefined,
|
||||
}}
|
||||
context="authentication"
|
||||
onRetry={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ export async function GET(request: Request) {
|
||||
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
// Keep minimal error logging for OAuth debugging if needed
|
||||
if (error) {
|
||||
console.error("OAuth code exchange failed:", error.message);
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const api = new BackendAPI();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RunGraph } from "./components/RunGraph";
|
||||
|
||||
export const BuilderActions = () => {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-[50%] z-[100] -translate-x-1/2">
|
||||
{/* TODO: Add Agent Output */}
|
||||
<RunGraph />
|
||||
{/* TODO: Add Schedule run button */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { StopIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const RunGraph = () => {
|
||||
const { runGraph, isSaving } = useRunGraph();
|
||||
const isGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={cn(
|
||||
"relative min-w-44 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
|
||||
)}
|
||||
onClick={() => runGraph()}
|
||||
>
|
||||
{!isGraphRunning && !isSaving ? (
|
||||
<PlayIcon className="mr-1 size-5" />
|
||||
) : (
|
||||
<StopIcon className="mr-1 size-5" />
|
||||
)}
|
||||
{isGraphRunning || isSaving ? "Stop Agent" : "Run Agent"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useNewSaveControl } from "../../../NewControlPanel/NewSaveControl/useNewSaveControl";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const useRunGraph = () => {
|
||||
const { onSubmit: onSaveGraph, isLoading: isSaving } = useNewSaveControl({
|
||||
showToast: false,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
const [{ flowID, flowVersion }, setQueryStates] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
flowExecutionID: parseAsString,
|
||||
});
|
||||
|
||||
const { mutateAsync: executeGraph } = usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
const { id } = response.data as GraphExecutionMeta;
|
||||
setQueryStates({
|
||||
flowExecutionID: id,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsGraphRunning(false);
|
||||
|
||||
toast({
|
||||
title: (error.detail as string) ?? "An unexpected error occurred.",
|
||||
description: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runGraph = async () => {
|
||||
setIsGraphRunning(true);
|
||||
await onSaveGraph(undefined);
|
||||
|
||||
// Todo : We need to save graph which has inputs and credentials inputs
|
||||
await executeGraph({
|
||||
graphId: flowID ?? "",
|
||||
graphVersion: flowVersion || null,
|
||||
data: {
|
||||
inputs: {},
|
||||
credentials_inputs: {},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
runGraph,
|
||||
isSaving,
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,11 @@ import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { GraphLoadingBox } from "./GraphLoadingBox";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { BuilderActions } from "../BuilderActions/BuilderActions";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
|
||||
export const Flow = () => {
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
@@ -18,8 +22,11 @@ export const Flow = () => {
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
useFlow();
|
||||
useFlowRealtime();
|
||||
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
const { isGraphRunning } = useGraphStore();
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
<div className="relative flex-1">
|
||||
@@ -37,7 +44,9 @@ export const Flow = () => {
|
||||
<Background />
|
||||
<Controls />
|
||||
<NewControlPanel />
|
||||
<BuilderActions />
|
||||
{isFlowContentLoading && <GraphLoadingBox />}
|
||||
{isGraphRunning && <RunningBackground />}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const GraphLoadingBox = () => {
|
||||
<div className="absolute left-[50%] top-[50%] z-[99] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col items-center gap-4 rounded-xlarge border border-gray-200 bg-white p-8 shadow-lg dark:border-gray-700 dark:bg-slate-800">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 animate-spin rounded-full border-4 border-gray-200 border-t-black dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||
<div className="absolute inset-0 animate-spin rounded-full border-4 border-violet-200 border-t-violet-500 dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Text variant="h4">Loading Flow</Text>
|
||||
@@ -0,0 +1,157 @@
|
||||
export const RunningBackground = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<style jsx>{`
|
||||
@keyframes rotateGradient {
|
||||
0% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#bc82f3 17%,
|
||||
#f5b9ea 24%,
|
||||
#8d99ff 35%,
|
||||
#aa6eee 58%,
|
||||
#ff6778 70%,
|
||||
#ffba71 81%,
|
||||
#c686ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
14.28% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#c686ff 17%,
|
||||
#bc82f3 24%,
|
||||
#f5b9ea 35%,
|
||||
#8d99ff 58%,
|
||||
#aa6eee 70%,
|
||||
#ff6778 81%,
|
||||
#ffba71 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
28.56% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#ffba71 17%,
|
||||
#c686ff 24%,
|
||||
#bc82f3 35%,
|
||||
#f5b9ea 58%,
|
||||
#8d99ff 70%,
|
||||
#aa6eee 81%,
|
||||
#ff6778 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
42.84% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#ff6778 17%,
|
||||
#ffba71 24%,
|
||||
#c686ff 35%,
|
||||
#bc82f3 58%,
|
||||
#f5b9ea 70%,
|
||||
#8d99ff 81%,
|
||||
#aa6eee 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
57.12% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#aa6eee 17%,
|
||||
#ff6778 24%,
|
||||
#ffba71 35%,
|
||||
#c686ff 58%,
|
||||
#bc82f3 70%,
|
||||
#f5b9ea 81%,
|
||||
#8d99ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
71.4% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#8d99ff 17%,
|
||||
#aa6eee 24%,
|
||||
#ff6778 35%,
|
||||
#ffba71 58%,
|
||||
#c686ff 70%,
|
||||
#bc82f3 81%,
|
||||
#f5b9ea 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
85.68% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#f5b9ea 17%,
|
||||
#8d99ff 24%,
|
||||
#aa6eee 35%,
|
||||
#ff6778 58%,
|
||||
#ffba71 70%,
|
||||
#c686ff 81%,
|
||||
#bc82f3 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
100% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#bc82f3 17%,
|
||||
#f5b9ea 24%,
|
||||
#8d99ff 35%,
|
||||
#aa6eee 58%,
|
||||
#ff6778 70%,
|
||||
#ffba71 81%,
|
||||
#c686ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
}
|
||||
.animate-gradient {
|
||||
animation: rotateGradient 8s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-xl"
|
||||
style={{
|
||||
borderWidth: "15px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-lg"
|
||||
style={{
|
||||
borderWidth: "10px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-md"
|
||||
style={{
|
||||
borderWidth: "6px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-sm"
|
||||
style={{
|
||||
borderWidth: "6px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import {
|
||||
useGetV1GetExecutionDetails,
|
||||
useGetV1GetSpecificGraph,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
@@ -8,16 +11,39 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { convertNodesPlusBlockInfoIntoCustomNodes } from "../../helper";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
export const useFlow = () => {
|
||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||
|
||||
const [{ flowID, flowVersion }] = useQueryStates({
|
||||
const updateNodeStatus = useNodeStore(
|
||||
useShallow((state) => state.updateNodeStatus),
|
||||
);
|
||||
const updateNodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.updateNodeExecutionResult),
|
||||
);
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
const [{ flowID, flowVersion, flowExecutionID }] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
flowExecutionID: parseAsString,
|
||||
});
|
||||
|
||||
const { data: executionDetails } = useGetV1GetExecutionDetails(
|
||||
flowID || "",
|
||||
flowExecutionID || "",
|
||||
{
|
||||
query: {
|
||||
select: (res) => res.data as GetV1GetExecutionDetails200,
|
||||
enabled: !!flowID && !!flowExecutionID,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
|
||||
flowID ?? "",
|
||||
flowVersion !== null ? { version: flowVersion } : {},
|
||||
@@ -57,21 +83,52 @@ export const useFlow = () => {
|
||||
}, [nodes, blocks]);
|
||||
|
||||
useEffect(() => {
|
||||
// adding nodes
|
||||
if (customNodes.length > 0) {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
addNodes(customNodes);
|
||||
}
|
||||
|
||||
// adding links
|
||||
if (graph?.links) {
|
||||
useEdgeStore.getState().setConnections([]);
|
||||
addLinks(graph.links);
|
||||
}
|
||||
}, [customNodes, addNodes, graph?.links]);
|
||||
|
||||
// update graph running status
|
||||
const isRunning =
|
||||
executionDetails?.status === AgentExecutionStatus.RUNNING ||
|
||||
executionDetails?.status === AgentExecutionStatus.QUEUED;
|
||||
setIsGraphRunning(isRunning);
|
||||
|
||||
// update node execution status in nodes
|
||||
if (
|
||||
executionDetails &&
|
||||
"node_executions" in executionDetails &&
|
||||
executionDetails.node_executions
|
||||
) {
|
||||
executionDetails.node_executions.forEach((nodeExecution) => {
|
||||
updateNodeStatus(nodeExecution.node_id, nodeExecution.status);
|
||||
});
|
||||
}
|
||||
|
||||
// update node execution results in nodes
|
||||
if (
|
||||
executionDetails &&
|
||||
"node_executions" in executionDetails &&
|
||||
executionDetails.node_executions
|
||||
) {
|
||||
executionDetails.node_executions.forEach((nodeExecution) => {
|
||||
updateNodeExecutionResult(nodeExecution.node_id, nodeExecution);
|
||||
});
|
||||
}
|
||||
}, [customNodes, addNodes, graph?.links, executionDetails, updateNodeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
useEdgeStore.getState().setConnections([]);
|
||||
setIsGraphRunning(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// In this hook, I am only keeping websocket related code.
|
||||
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
|
||||
export const useFlowRealtime = () => {
|
||||
const api = useBackendAPI();
|
||||
const updateNodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.updateNodeExecutionResult),
|
||||
);
|
||||
const updateStatus = useNodeStore(
|
||||
useShallow((state) => state.updateNodeStatus),
|
||||
);
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
|
||||
const [{ flowExecutionID, flowID }] = useQueryStates({
|
||||
flowExecutionID: parseAsString,
|
||||
flowID: parseAsString,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const deregisterNodeExecutionEvent = api.onWebSocketMessage(
|
||||
"node_execution_event",
|
||||
(data) => {
|
||||
if (data.graph_exec_id != flowExecutionID) {
|
||||
return;
|
||||
}
|
||||
// TODO: Update the states of nodes
|
||||
updateNodeExecutionResult(
|
||||
data.node_id,
|
||||
data as unknown as NodeExecutionResult,
|
||||
);
|
||||
updateStatus(data.node_id, data.status);
|
||||
},
|
||||
);
|
||||
|
||||
const deregisterGraphExecutionStatusEvent = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
(graphExecution) => {
|
||||
if (graphExecution.id != flowExecutionID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
graphExecution.status === AgentExecutionStatus.RUNNING ||
|
||||
graphExecution.status === AgentExecutionStatus.QUEUED;
|
||||
|
||||
setIsGraphRunning(isRunning);
|
||||
},
|
||||
);
|
||||
|
||||
const deregisterGraphExecutionSubscription =
|
||||
flowID && flowExecutionID
|
||||
? api.onWebSocketConnect(() => {
|
||||
// Subscribe to execution updates
|
||||
api
|
||||
.subscribeToGraphExecution(flowExecutionID as GraphExecutionID) // TODO: We are currently using a manual type, we need to fix it in future
|
||||
.then(() => {
|
||||
console.debug(
|
||||
`Subscribed to updates for execution #${flowExecutionID}`,
|
||||
);
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error(
|
||||
`Failed to subscribe to updates for execution #${flowExecutionID}:`,
|
||||
error,
|
||||
),
|
||||
);
|
||||
})
|
||||
: () => {};
|
||||
|
||||
return () => {
|
||||
deregisterNodeExecutionEvent();
|
||||
deregisterGraphExecutionSubscription();
|
||||
deregisterGraphExecutionStatusEvent();
|
||||
};
|
||||
}, [api, flowExecutionID]);
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -147,6 +147,7 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="min-w-10"
|
||||
onClick={() => removeProperty(key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { StickyNoteBlock } from "./StickyNoteBlock";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { StandardNodeBlock } from "./StandardNodeBlock";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -17,6 +19,8 @@ export type CustomNodeData = {
|
||||
outputSchema: RJSFSchema;
|
||||
uiType: BlockUIType;
|
||||
block_id: string;
|
||||
status?: AgentExecutionStatus;
|
||||
nodeExecutionResult?: NodeExecutionResult;
|
||||
// TODO : We need better type safety for the following backend fields.
|
||||
costs: BlockCost[];
|
||||
categories: BlockInfoCategoriesItem[];
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { NodeCost } from "./components/NodeCost";
|
||||
import { NodeBadges } from "./components/NodeBadges";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { nodeStyleBasedOnStatus } from "./helpers";
|
||||
import { NodeDataRenderer } from "./components/NodeDataRenderer";
|
||||
|
||||
type StandardNodeBlockType = {
|
||||
data: CustomNodeData;
|
||||
@@ -23,57 +26,60 @@ export const StandardNodeBlock = ({
|
||||
(state) => state.nodeAdvancedStates[nodeId] || false,
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
|
||||
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-12 rounded-xl bg-gradient-to-br from-white to-slate-50/30 shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
|
||||
"z-12 max-w-[370px] rounded-xl shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
|
||||
selected && "shadow-2xl ring-2 ring-slate-200",
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex h-auto flex-col gap-2 rounded-xl border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
|
||||
{/* Upper section */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="tracking-tight text-slate-800"
|
||||
>
|
||||
{beautifyString(data.title)}
|
||||
</Text>
|
||||
<Text variant="small" className="!font-medium !text-slate-500">
|
||||
#{nodeId.split("-")[0]}
|
||||
</Text>
|
||||
<div className="rounded-xl bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex h-auto flex-col gap-2 rounded-xl border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
|
||||
{/* Upper section */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="tracking-tight text-slate-800"
|
||||
>
|
||||
{beautifyString(data.title)}
|
||||
</Text>
|
||||
<Text variant="small" className="!font-medium !text-slate-500">
|
||||
#{nodeId.split("-")[0]}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Lower section */}
|
||||
<div className="flex space-x-2">
|
||||
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
|
||||
<NodeBadges categories={data.categories} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Lower section */}
|
||||
<div className="flex space-x-2">
|
||||
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
|
||||
<NodeBadges categories={data.categories} />
|
||||
{/* Input Handles */}
|
||||
<div className="bg-white pb-6 pr-6">
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Advanced Button */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-slate-200/50 bg-white px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
|
||||
checked={showAdvanced}
|
||||
/>
|
||||
</div>
|
||||
{/* Output Handles */}
|
||||
<OutputHandler outputSchema={data.outputSchema} nodeId={nodeId} />
|
||||
|
||||
{/* Input Handles */}
|
||||
<div className="bg-white/40 pb-6 pr-6">
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
<NodeDataRenderer nodeId={nodeId} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Button */}
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xl border-t border-slate-200/50 bg-gradient-to-r from-slate-50/60 to-white/80 px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
|
||||
checked={showAdvanced}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Handles */}
|
||||
<OutputHandler outputSchema={data.outputSchema} nodeId={nodeId} />
|
||||
{status && <NodeExecutionBadge status={status} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
|
||||
style={{ transform: `rotate(${angle}deg)` }}
|
||||
>
|
||||
<Text variant="h3" className="tracking-tight text-slate-800">
|
||||
Notes #{id}
|
||||
Notes #{id.split("-")[0]}
|
||||
</Text>
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import useCredits from "@/hooks/useCredits";
|
||||
import { CoinIcon } from "@phosphor-icons/react";
|
||||
import { isCostFilterMatch } from "../../../../helper";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const NodeCost = ({
|
||||
blockCosts,
|
||||
@@ -13,9 +14,10 @@ export const NodeCost = ({
|
||||
nodeId: string;
|
||||
}) => {
|
||||
const { formatCredits } = useCredits();
|
||||
const hardcodedValues = useNodeStore((state) =>
|
||||
state.getHardCodedValues(nodeId),
|
||||
const hardcodedValues = useNodeStore(
|
||||
useShallow((state) => state.getHardCodedValues(nodeId)),
|
||||
);
|
||||
|
||||
const blockCost =
|
||||
blockCosts &&
|
||||
blockCosts.find((cost) =>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import {
|
||||
ArrowSquareInIcon,
|
||||
CaretDownIcon,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const nodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.getNodeExecutionResult(nodeId)),
|
||||
);
|
||||
|
||||
const data = {
|
||||
"[Input]": nodeExecutionResult?.input_data,
|
||||
...nodeExecutionResult?.output_data,
|
||||
};
|
||||
|
||||
// Don't render if there's no data
|
||||
if (!nodeExecutionResult || Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to Fix - when we are on build page and try to rerun the graph again, it gives error
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="flex max-w-[350px] flex-col gap-4">
|
||||
{Object.entries(data || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Pin:
|
||||
</Text>
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{beautifyString(key)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Data:
|
||||
</Text>
|
||||
<div className="relative">
|
||||
<Text
|
||||
variant="small"
|
||||
className="rounded-xlarge bg-zinc-50 p-3 text-slate-700"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</Text>
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
{/* TODO: Add tooltip for each button and also make all these blocks working */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<ArrowSquareInIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<CopyIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* TODO: Currently this button is not working, need to make it working */}
|
||||
<Button variant="outline" size="small" className="w-fit self-start">
|
||||
View More
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const statusStyles: Record<AgentExecutionStatus, string> = {
|
||||
INCOMPLETE: "text-slate-700 border-slate-400",
|
||||
QUEUED: "text-blue-700 border-blue-400",
|
||||
RUNNING: "text-amber-700 border-amber-400",
|
||||
COMPLETED: "text-green-700 border-green-400",
|
||||
TERMINATED: "text-orange-700 border-orange-400",
|
||||
FAILED: "text-red-700 border-red-400",
|
||||
};
|
||||
|
||||
export const NodeExecutionBadge = ({
|
||||
status,
|
||||
}: {
|
||||
status: AgentExecutionStatus;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end rounded-b-xl py-2 pr-4">
|
||||
<Badge
|
||||
className={cn(statusStyles[status], "gap-2 rounded-full bg-white")}
|
||||
>
|
||||
{status}
|
||||
{status === AgentExecutionStatus.RUNNING && (
|
||||
<LoadingSpinner className="size-4" />
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
||||
INCOMPLETE: "ring-slate-300 bg-slate-300",
|
||||
QUEUED: " ring-blue-300 bg-blue-300",
|
||||
RUNNING: "ring-amber-300 bg-amber-300",
|
||||
COMPLETED: "ring-green-300 bg-green-300",
|
||||
TERMINATED: "ring-orange-300 bg-orange-300 ",
|
||||
FAILED: "ring-red-300 bg-red-300",
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export const OutputHandler = ({
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className="flex items-center gap-2 font-medium text-slate-700"
|
||||
className="flex items-center gap-2 !font-semibold text-slate-700"
|
||||
>
|
||||
Output{" "}
|
||||
<CaretDownIcon
|
||||
|
||||
@@ -79,7 +79,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-[350px] space-y-1">
|
||||
<div className="w-[350px] space-y-1 pt-4">
|
||||
{label && schema.type && (
|
||||
<label htmlFor={fieldId} className="flex items-center gap-1">
|
||||
{!suppressHandle && !fromAnyOf && !isCredential && (
|
||||
|
||||
@@ -19,7 +19,9 @@ import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
export const NewSaveControl = () => {
|
||||
const { form, onSubmit, isLoading, graphVersion } = useNewSaveControl();
|
||||
const { form, onSubmit, isLoading, graphVersion } = useNewSaveControl({
|
||||
showToast: true,
|
||||
});
|
||||
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
|
||||
return (
|
||||
<Popover onOpenChange={setSaveControlOpen}>
|
||||
@@ -111,6 +113,7 @@ export const NewSaveControl = () => {
|
||||
data-id="save-control-save-agent"
|
||||
data-testid="save-control-save-agent-button"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Save Agent
|
||||
</Button>
|
||||
|
||||
@@ -25,7 +25,11 @@ const formSchema = z.object({
|
||||
|
||||
type SaveableGraphFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const useNewSaveControl = () => {
|
||||
export const useNewSaveControl = ({
|
||||
showToast = true,
|
||||
}: {
|
||||
showToast?: boolean;
|
||||
}) => {
|
||||
const { setSaveControlOpen } = useControlPanelStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,9 +64,11 @@ export const useNewSaveControl = () => {
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
@@ -88,9 +94,11 @@ export const useNewSaveControl = () => {
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1GetSpecificGraphQueryKey(data.id),
|
||||
});
|
||||
@@ -113,6 +121,41 @@ export const useNewSaveControl = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SaveableGraphFormValues | undefined) => {
|
||||
const graphNodes = useNodeStore.getState().getBackendNodes();
|
||||
const graphLinks = useEdgeStore.getState().getBackendLinks();
|
||||
|
||||
if (graph && graph.id) {
|
||||
const data: Graph = {
|
||||
id: graph.id,
|
||||
name:
|
||||
values?.name || graph.name || `New Agent ${new Date().toISOString()}`,
|
||||
description: values?.description ?? graph.description ?? "",
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
if (graphsEquivalent(graph, data)) {
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "No changes to save",
|
||||
description: "The graph is the same as the saved version.",
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await updateGraph({ graphId: graph.id, data: data });
|
||||
} else {
|
||||
const data: Graph = {
|
||||
name: values?.name || `New Agent ${new Date().toISOString()}`,
|
||||
description: values?.description || "",
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
await createNewGraph({ data: { graph: data } });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Ctrl+S / Cmd+S keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
@@ -127,7 +170,7 @@ export const useNewSaveControl = () => {
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form]);
|
||||
}, [onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
@@ -138,38 +181,6 @@ export const useNewSaveControl = () => {
|
||||
}
|
||||
}, [graph, form]);
|
||||
|
||||
const onSubmit = async (values: SaveableGraphFormValues) => {
|
||||
const graphNodes = useNodeStore.getState().getBackendNodes();
|
||||
const graphLinks = useEdgeStore.getState().getBackendLinks();
|
||||
|
||||
if (graph && graph.id) {
|
||||
const data: Graph = {
|
||||
id: graph.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
if (graphsEquivalent(graph, data)) {
|
||||
toast({
|
||||
title: "No changes to save",
|
||||
description: "The graph is the same as the saved version.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await updateGraph({ graphId: graph.id, data: data });
|
||||
} else {
|
||||
const data: Graph = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
await createNewGraph({ data: { graph: data } });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
isLoading: isCreating || isUpdating,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
beautifyString,
|
||||
cn,
|
||||
fillObjectDefaultsFromSchema,
|
||||
getValue,
|
||||
hasNonNullNonObjectValue,
|
||||
isObject,
|
||||
@@ -158,37 +159,6 @@ export const CustomNode = React.memo(
|
||||
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
|
||||
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
|
||||
|
||||
const fillDefaults = useCallback((obj: any, schema: any) => {
|
||||
// Iterate over the schema properties
|
||||
for (const key in schema.properties) {
|
||||
if (schema.properties.hasOwnProperty(key)) {
|
||||
const propertySchema = schema.properties[key];
|
||||
|
||||
// If the property is not in the object, initialize it with the default value
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
if (propertySchema.default !== undefined) {
|
||||
obj[key] = propertySchema.default;
|
||||
} else if (propertySchema.type === "object") {
|
||||
// Recursively fill defaults for nested objects
|
||||
obj[key] = fillDefaults({}, propertySchema);
|
||||
} else if (propertySchema.type === "array") {
|
||||
// Recursively fill defaults for arrays
|
||||
obj[key] = fillDefaults([], propertySchema);
|
||||
}
|
||||
} else {
|
||||
// If the property exists, recursively fill defaults for nested objects/arrays
|
||||
if (propertySchema.type === "object") {
|
||||
obj[key] = fillDefaults(obj[key], propertySchema);
|
||||
} else if (propertySchema.type === "array") {
|
||||
obj[key] = fillDefaults(obj[key], propertySchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, []);
|
||||
|
||||
const setHardcodedValues = useCallback(
|
||||
(values: any) => {
|
||||
updateNodeData(id, { hardcodedValues: values });
|
||||
@@ -231,17 +201,19 @@ export const CustomNode = React.memo(
|
||||
|
||||
useEffect(() => {
|
||||
isInitialSetup.current = false;
|
||||
if (data.backend_id) return; // don't auto-modify existing nodes
|
||||
|
||||
if (data.uiType === BlockUIType.AGENT) {
|
||||
setHardcodedValues({
|
||||
...data.hardcodedValues,
|
||||
inputs: fillDefaults(
|
||||
inputs: fillObjectDefaultsFromSchema(
|
||||
data.hardcodedValues.inputs ?? {},
|
||||
data.inputSchema,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setHardcodedValues(
|
||||
fillDefaults(data.hardcodedValues, data.inputSchema),
|
||||
fillObjectDefaultsFromSchema(data.hardcodedValues, data.inputSchema),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
@@ -857,7 +829,9 @@ export const CustomNode = React.memo(
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">#{id.split("-")[0]}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
#{(data.backend_id || id).split("-")[0]}
|
||||
</span>
|
||||
|
||||
<div className="w-auto grow" />
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import RunnerUIWrapper, { RunnerUIWrapperRef } from "../RunnerUIWrapper";
|
||||
import OttoChatWidget from "@/app/(platform)/build/components/legacy-builder/OttoChatWidget";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useCopyPaste } from "../../../../../../hooks/useCopyPaste";
|
||||
import { useCopyPaste } from "../useCopyPaste";
|
||||
import NewControlPanel from "@/app/(platform)/build/components/NewControlPanel/NewControlPanel";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { BuildActionBar } from "../BuildActionBar";
|
||||
|
||||
@@ -6,6 +6,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import type { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { askOtto } from "@/app/(platform)/build/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
interface Message {
|
||||
type: "user" | "assistant";
|
||||
@@ -129,7 +130,7 @@ export default function OttoChatWidget({
|
||||
};
|
||||
|
||||
// Don't render the chat widget if we're not on the build page or in local mode
|
||||
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD") {
|
||||
if (environment.isLocal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Shepherd from "shepherd.js";
|
||||
import "shepherd.js/dist/css/shepherd.css";
|
||||
import { sendGAEvent } from "@/services/analytics/google-analytics";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export const startTutorial = (
|
||||
emptyNodeList: (forceEmpty: boolean) => boolean,
|
||||
@@ -555,7 +555,7 @@ export const startTutorial = (
|
||||
"use client";
|
||||
console.debug("sendTutorialStep");
|
||||
|
||||
sendGAEvent("event", "tutorial_step_shown", { value: step.id });
|
||||
analytics.sendGAEvent("event", "tutorial_step_shown", { value: step.id });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface GraphStore {
|
||||
isGraphRunning: boolean;
|
||||
setIsGraphRunning: (isGraphRunning: boolean) => void;
|
||||
}
|
||||
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
isGraphRunning: false,
|
||||
setIsGraphRunning: (isGraphRunning: boolean) => set({ isGraphRunning }),
|
||||
}));
|
||||
@@ -4,6 +4,8 @@ import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
|
||||
import { Node } from "@/app/api/__generated__/models/node";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
type NodeStore = {
|
||||
nodes: CustomNode[];
|
||||
@@ -22,6 +24,15 @@ type NodeStore = {
|
||||
getHardCodedValues: (nodeId: string) => Record<string, any>;
|
||||
convertCustomNodeToBackendNode: (node: CustomNode) => Node;
|
||||
getBackendNodes: () => Node[];
|
||||
|
||||
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void;
|
||||
getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined;
|
||||
|
||||
updateNodeExecutionResult: (
|
||||
nodeId: string,
|
||||
result: NodeExecutionResult,
|
||||
) => void;
|
||||
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -103,4 +114,27 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
get().convertCustomNodeToBackendNode(node),
|
||||
);
|
||||
},
|
||||
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, status } } : n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
getNodeStatus: (nodeId: string) => {
|
||||
return get().nodes.find((n) => n.id === nodeId)?.data?.status;
|
||||
},
|
||||
|
||||
updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, data: { ...n.data, nodeExecutionResult: result } }
|
||||
: n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
getNodeExecutionResult: (nodeId: string) => {
|
||||
return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -105,6 +105,7 @@ export const CredentialsInput: FC<{
|
||||
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
|
||||
siblingInputs?: Record<string, any>;
|
||||
hideIfSingleCredentialAvailable?: boolean;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
}> = ({
|
||||
schema,
|
||||
className,
|
||||
@@ -112,6 +113,7 @@ export const CredentialsInput: FC<{
|
||||
onSelectCredentials,
|
||||
siblingInputs,
|
||||
hideIfSingleCredentialAvailable = true,
|
||||
onLoaded,
|
||||
}) => {
|
||||
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
|
||||
useState(false);
|
||||
@@ -129,6 +131,13 @@ export const CredentialsInput: FC<{
|
||||
const api = useBackendAPI();
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
|
||||
// Report loaded state to parent
|
||||
useEffect(() => {
|
||||
if (onLoaded) {
|
||||
onLoaded(Boolean(credentials && credentials.isLoading === false));
|
||||
}
|
||||
}, [credentials, onLoaded]);
|
||||
|
||||
// Deselect credentials if they do not exist (e.g. provider was changed)
|
||||
useEffect(() => {
|
||||
if (!credentials || !("savedCredentials" in credentials)) return;
|
||||
|
||||
@@ -42,8 +42,16 @@ function isVideoUrl(url: string): boolean {
|
||||
if (url.includes("youtube.com/watch") || url.includes("youtu.be/")) {
|
||||
return true;
|
||||
}
|
||||
if (url.includes("vimeo.com/")) {
|
||||
return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.hostname === "vimeo.com" ||
|
||||
parsed.hostname === "www.vimeo.com"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, treat as not a Vimeo URL.
|
||||
}
|
||||
return videoExtensions.some((ext) => url.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecu
|
||||
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
|
||||
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export type RunVariant =
|
||||
| "manual"
|
||||
@@ -78,6 +79,10 @@ export function useAgentRunModal(
|
||||
agent.graph_id,
|
||||
).queryKey,
|
||||
});
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: agent.name,
|
||||
id: agent.graph_id,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
@@ -105,6 +110,11 @@ export function useAgentRunModal(
|
||||
agent.graph_id,
|
||||
),
|
||||
});
|
||||
analytics.sendDatafastEvent("schedule_agent", {
|
||||
name: agent.name,
|
||||
id: agent.graph_id,
|
||||
cronExpression: cronExpression,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { AgentRunOutputView } from "./agent-run-output-view";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export function AgentRunDetailsView({
|
||||
agent,
|
||||
@@ -131,7 +132,13 @@ export function AgentRunDetailsView({
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
)
|
||||
.then(({ id }) => onRun(id))
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent preset"));
|
||||
}
|
||||
|
||||
@@ -142,7 +149,13 @@ export function AgentRunDetailsView({
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
)
|
||||
.then(({ id }) => onRun(id))
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent"));
|
||||
}, [api, graph, run, onRun, toastOnFail]);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
|
||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export function AgentRunDraftView({
|
||||
graph,
|
||||
@@ -197,6 +198,12 @@ export function AgentRunDraftView({
|
||||
}
|
||||
// Mark run agent onboarding step as completed
|
||||
completeOnboardingStep("MARKETPLACE_RUN_AGENT");
|
||||
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
|
||||
if (runCount > 0) {
|
||||
completeOnboardingStep("RE_RUN_AGENT");
|
||||
}
|
||||
@@ -373,6 +380,12 @@ export function AgentRunDraftView({
|
||||
})
|
||||
.catch(toastOnFail("set up agent run schedule"));
|
||||
|
||||
analytics.sendDatafastEvent("schedule_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
cronExpression: cronExpression,
|
||||
});
|
||||
|
||||
if (schedule && onCreateSchedule) onCreateSchedule(schedule);
|
||||
},
|
||||
[api, graph, inputValues, inputCredentials, onCreateSchedule, toastOnFail],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getV2ListLibraryAgentsResponse } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
|
||||
|
||||
export function filterAgents(agents: LibraryAgent[], term?: string | null) {
|
||||
const t = term?.trim().toLowerCase();
|
||||
if (!t) return agents;
|
||||
return agents.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(t) ||
|
||||
a.description.toLowerCase().includes(t),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitialData(
|
||||
cachedAgents: LibraryAgent[],
|
||||
searchTerm: string | null,
|
||||
pageSize: number,
|
||||
) {
|
||||
const filtered = filterAgents(
|
||||
cachedAgents as unknown as LibraryAgent[],
|
||||
searchTerm,
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstPageAgents: LibraryAgent[] = filtered.slice(0, pageSize);
|
||||
const totalItems = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
|
||||
const firstPage: getV2ListLibraryAgentsResponse = {
|
||||
status: 200,
|
||||
data: {
|
||||
agents: firstPageAgents,
|
||||
pagination: {
|
||||
total_items: totalItems,
|
||||
total_pages: totalPages,
|
||||
current_page: 1,
|
||||
page_size: pageSize,
|
||||
},
|
||||
} satisfies LibraryAgentResponse,
|
||||
headers: new Headers(),
|
||||
};
|
||||
|
||||
return { pageParams: [1], pages: [firstPage] };
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
import { useLibraryAgentsStore } from "@/hooks/useLibraryAgents/store";
|
||||
import { getInitialData } from "./helpers";
|
||||
|
||||
export const useLibraryAgentList = () => {
|
||||
const { searchTerm, librarySort } = useLibraryPageContext();
|
||||
const { agents: cachedAgents } = useLibraryAgentsStore();
|
||||
|
||||
const {
|
||||
data: agents,
|
||||
fetchNextPage,
|
||||
@@ -21,6 +25,7 @@ export const useLibraryAgentList = () => {
|
||||
},
|
||||
{
|
||||
query: {
|
||||
initialData: getInitialData(cachedAgents, searchTerm, 8),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const pagination = (lastPage.data as LibraryAgentResponse).pagination;
|
||||
const isMore =
|
||||
|
||||
@@ -8,18 +8,18 @@ import { EmailNotAllowedModal } from "@/components/auth/EmailNotAllowedModal";
|
||||
import { GoogleOAuthButton } from "@/components/auth/GoogleOAuthButton";
|
||||
import Turnstile from "@/components/auth/Turnstile";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { getBehaveAs } from "@/lib/utils";
|
||||
import { LoadingLogin } from "./components/LoadingLogin";
|
||||
import { useLoginPage } from "./useLoginPage";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
export default function LoginPage() {
|
||||
const {
|
||||
user,
|
||||
form,
|
||||
feedback,
|
||||
turnstile,
|
||||
captchaKey,
|
||||
isLoading,
|
||||
isLoggedIn,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
isGoogleLoading,
|
||||
@@ -30,7 +30,7 @@ export default function LoginPage() {
|
||||
handleCloseNotAllowedModal,
|
||||
} = useLoginPage();
|
||||
|
||||
if (isUserLoading || isLoggedIn) {
|
||||
if (isUserLoading || user) {
|
||||
return <LoadingLogin />;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function LoginPage() {
|
||||
type="login"
|
||||
message={feedback}
|
||||
isError={!!feedback}
|
||||
behaveAs={getBehaveAs()}
|
||||
behaveAs={environment.getBehaveAs()}
|
||||
/>
|
||||
</Form>
|
||||
<AuthCard.BottomText
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { BehaveAs, getBehaveAs } from "@/lib/utils";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -8,6 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
export function useLoginPage() {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
@@ -18,7 +18,7 @@ export function useLoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||
const isCloudEnv = getBehaveAs() === BehaveAs.CLOUD;
|
||||
const isCloudEnv = environment.isCloud();
|
||||
const isVercelPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === "preview";
|
||||
|
||||
const turnstile = useTurnstile({
|
||||
@@ -162,7 +162,7 @@ export function useLoginPage() {
|
||||
feedback,
|
||||
turnstile,
|
||||
captchaKey,
|
||||
isLoggedIn: !!user,
|
||||
user,
|
||||
isLoading,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
|
||||
@@ -6,10 +6,10 @@ import Link from "next/link";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAgentInfo } from "./useAgentInfo";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
interface AgentInfoProps {
|
||||
user: User | null;
|
||||
agentId: string;
|
||||
name: string;
|
||||
creator: string;
|
||||
shortDescription: string;
|
||||
@@ -20,11 +20,12 @@ interface AgentInfoProps {
|
||||
lastUpdated: string;
|
||||
version: string;
|
||||
storeListingVersionId: string;
|
||||
libraryAgent: LibraryAgent | undefined;
|
||||
isAgentAddedToLibrary: boolean;
|
||||
}
|
||||
|
||||
export const AgentInfo = ({
|
||||
user,
|
||||
agentId,
|
||||
name,
|
||||
creator,
|
||||
shortDescription,
|
||||
@@ -35,7 +36,7 @@ export const AgentInfo = ({
|
||||
lastUpdated,
|
||||
version,
|
||||
storeListingVersionId,
|
||||
libraryAgent,
|
||||
isAgentAddedToLibrary,
|
||||
}: AgentInfoProps) => {
|
||||
const {
|
||||
handleDownload,
|
||||
@@ -82,11 +83,15 @@ export const AgentInfo = ({
|
||||
"transition-colors duration-200 hover:bg-violet-500 disabled:bg-zinc-400",
|
||||
)}
|
||||
data-testid={"agent-add-library-button"}
|
||||
onClick={handleLibraryAction}
|
||||
disabled={isAddingAgentToLibrary}
|
||||
onClick={() =>
|
||||
handleLibraryAction({
|
||||
isAddingAgentFirstTime: !isAgentAddedToLibrary,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="justify-start font-sans text-sm font-medium leading-snug text-primary-foreground">
|
||||
{libraryAgent ? "See runs" : "Add to library"}
|
||||
{isAgentAddedToLibrary ? "See runs" : "Add to library"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -96,7 +101,7 @@ export const AgentInfo = ({
|
||||
"transition-colors duration-200 hover:bg-zinc-200/70 disabled:bg-zinc-200/40",
|
||||
)}
|
||||
data-testid={"agent-download-button"}
|
||||
onClick={handleDownload}
|
||||
onClick={() => handleDownload(agentId, name)}
|
||||
disabled={isDownloadingAgent}
|
||||
>
|
||||
<div className="justify-start text-center font-sans text-sm font-medium leading-snug text-zinc-800">
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { usePostV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import {
|
||||
getGetV2ListLibraryAgentsQueryKey,
|
||||
usePostV2AddMarketplaceAgent,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useGetV2DownloadAgentFile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { analytics } from "@/services/analytics";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface UseAgentInfoProps {
|
||||
storeListingVersionId: string;
|
||||
@@ -14,31 +19,12 @@ export const useAgentInfo = ({ storeListingVersionId }: UseAgentInfoProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const { completeStep } = useOnboarding();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
mutate: addMarketplaceAgentToLibrary,
|
||||
mutateAsync: addMarketplaceAgentToLibrary,
|
||||
isPending: isAddingAgentToLibrary,
|
||||
} = usePostV2AddMarketplaceAgent({
|
||||
mutation: {
|
||||
onSuccess: ({ data }) => {
|
||||
completeStep("MARKETPLACE_ADD_AGENT");
|
||||
router.push(`/library/agents/${(data as LibraryAgent).id}`);
|
||||
toast({
|
||||
title: "Agent Added",
|
||||
description: "Redirecting to your library...",
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
Sentry.captureException(error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to add agent to library. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
} = usePostV2AddMarketplaceAgent();
|
||||
|
||||
const { refetch: downloadAgent, isFetching: isDownloadingAgent } =
|
||||
useGetV2DownloadAgentFile(storeListingVersionId, {
|
||||
@@ -50,13 +36,50 @@ export const useAgentInfo = ({ storeListingVersionId }: UseAgentInfoProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleLibraryAction = async () => {
|
||||
addMarketplaceAgentToLibrary({
|
||||
data: { store_listing_version_id: storeListingVersionId },
|
||||
});
|
||||
const handleLibraryAction = async ({
|
||||
isAddingAgentFirstTime,
|
||||
}: {
|
||||
isAddingAgentFirstTime: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { data: response } = await addMarketplaceAgentToLibrary({
|
||||
data: { store_listing_version_id: storeListingVersionId },
|
||||
});
|
||||
|
||||
const data = response as LibraryAgent;
|
||||
|
||||
if (isAddingAgentFirstTime) {
|
||||
completeStep("MARKETPLACE_ADD_AGENT");
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
|
||||
analytics.sendDatafastEvent("add_to_library", {
|
||||
name: data.name,
|
||||
id: data.id,
|
||||
});
|
||||
}
|
||||
|
||||
router.push(`/library/agents/${data.id}`);
|
||||
|
||||
toast({
|
||||
title: "Agent Added",
|
||||
description: "Redirecting to your library...",
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to add agent to library. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownload = async (agentId: string, agentName: string) => {
|
||||
try {
|
||||
const { data: file } = await downloadAgent();
|
||||
|
||||
@@ -74,6 +97,11 @@ export const useAgentInfo = ({ storeListingVersionId }: UseAgentInfoProps) => {
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
analytics.sendDatafastEvent("download_agent", {
|
||||
name: agentName,
|
||||
id: agentId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Download Complete",
|
||||
description: "Your agent has been successfully downloaded.",
|
||||
|
||||
@@ -82,6 +82,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
<div className="w-full md:w-auto md:shrink-0">
|
||||
<AgentInfo
|
||||
user={user}
|
||||
agentId={agent.active_version_id ?? "–"}
|
||||
name={agent.agent_name}
|
||||
creator={agent.creator}
|
||||
shortDescription={agent.sub_heading}
|
||||
@@ -92,7 +93,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
lastUpdated={agent.last_updated.toISOString()}
|
||||
version={agent.versions[agent.versions.length - 1]}
|
||||
storeListingVersionId={agent.store_listing_version_id}
|
||||
libraryAgent={libraryAgent}
|
||||
isAgentAddedToLibrary={Boolean(libraryAgent)}
|
||||
/>
|
||||
</div>
|
||||
<AgentImages
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
import { SettingsForm } from "@/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useTimezoneDetection } from "@/hooks/useTimezoneDetection";
|
||||
import { useTimezoneDetection } from "@/app/(platform)/profile/(user)/settings/useTimezoneDetection";
|
||||
import * as React from "react";
|
||||
import SettingsLoading from "./loading";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -28,6 +28,7 @@ export default function SettingsPage() {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useTimezoneDetection(timezone);
|
||||
|
||||
const { user, isUserLoading } = useSupabase();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user