Compare commits

...

121 Commits
0.0.1 ... main

Author SHA1 Message Date
Davidson Gomes
7ff7a1455c Merge branch 'release/0.1.0' 2025-05-24 11:01:14 -03:00
Davidson Gomes
b4939b0fca chore(frontend): update .dockerignore and .gitignore to correct lib directory entry; add new utility and file handling modules 2025-05-24 11:00:55 -03:00
Davidson Gomes
eb7bb06ef3 Merge tag '0.1.0' into develop
v
2025-05-24 10:58:00 -03:00
Davidson Gomes
53e2c7016c Merge branch 'release/0.1.0' 2025-05-24 10:57:59 -03:00
Davidson Gomes
27a367972b chore(frontend): update .dockerignore and Dockerfile for frontend build 2025-05-24 10:57:43 -03:00
Davidson Gomes
c9087b1918 Merge tag '0.1.0' into develop
v
2025-05-24 10:49:20 -03:00
Davidson Gomes
772263f7d7 Merge branch 'release/0.1.0' 2025-05-24 10:49:18 -03:00
Davidson Gomes
e2d3483de2 chore(frontend): add .dockerignore file to exclude unnecessary files from Docker context 2025-05-24 10:48:51 -03:00
Davidson Gomes
62a47cc7d2 Merge tag '0.1.0' into develop
v
2025-05-24 10:42:54 -03:00
Davidson Gomes
fe778b3eb9 Merge branch 'release/0.1.0' 2025-05-24 10:42:52 -03:00
Davidson Gomes
4e1f663787 chore(frontend): update Dockerfile to use --no-frozen-lockfile for pnpm install 2025-05-24 10:42:34 -03:00
Davidson Gomes
9ef4835344 docs(readme): update frontend configuration section to reflect correct .env file name 2025-05-24 10:34:30 -03:00
Davidson Gomes
dc01331696 Merge tag '0.1.0' into develop
v
2025-05-24 10:32:22 -03:00
Davidson Gomes
f1f2ba8823 Merge branch 'release/0.1.0' 2025-05-24 10:32:19 -03:00
Davidson Gomes
027c096377 chore(changelog): update CHANGELOG for version 0.1.0 with new features and changes 2025-05-24 10:30:58 -03:00
Davidson Gomes
c4a4e5fd68 feat(makefile): update run command to exclude frontend and log files during reload 2025-05-24 10:27:30 -03:00
Davidson Gomes
956d16a854 feat(frontend): add initial frontend structure with components, services, and assets 2025-05-24 09:51:34 -03:00
Davidson Gomes
482c1693d1 feat(env): add AI engine configuration option to .env.example and update README for improved clarity 2025-05-19 15:34:42 -03:00
Davidson Gomes
e2e756156f Merge branch 'develop' of github.com:EvolutionAPI/evo-ai into develop 2025-05-19 15:22:44 -03:00
Davidson Gomes
cf24a7ce5d feat(api): integrate new AI engines and update chat routes for dynamic agent handling 2025-05-19 15:22:37 -03:00
Davidson Gomes
3e8c322e79
Merge pull request #20 from gomessguii/fix/url-safe-characters
feat(custom_tools): URL encode path parameters and improve response handling
2025-05-19 08:35:19 -03:00
Guilherme Gomes
9135aa59d6 feat(custom_tools): URL encode path parameters and improve response handling 2025-05-19 01:21:30 -03:00
Davidson Gomes
ef4e4ee1c7
Merge pull request #17 from Danielpeter-99/main
feat(mcp): enhance MCP server creation with tool discovery and async handling
2025-05-18 08:13:07 -03:00
Arley Daniel Peter
7a9d3e1477
feat: Add MCP tools discovery functionality
- Implement async MCP server tool discovery
- Add sync wrapper for tool discovery
- Include tool metadata serialization
- Add proper file documentation and licensing
2025-05-17 16:35:34 -03:00
Arley Daniel Peter
b619d88d4e
feat: if tools are empty, auto-fetch and save 2025-05-17 16:34:01 -03:00
Arley Daniel Peter
2c7e5d0528
feat: update schemas to make tools optional
since they are automatically fetched, no need to make them mandatory
2025-05-17 16:33:17 -03:00
Arley Daniel Peter
c469bf1998
feat: use run_in_threadpool to fetch tools 2025-05-17 16:32:31 -03:00
Davidson Gomes
25db7e8a9a
Merge pull request #16 from VCalazans/FEAT/CREATE-DELAY-NODE
 feat: Create node dealay
2025-05-17 11:42:43 -03:00
Victor Calazans
d01644c00c Change doc
Change doc
2025-05-17 09:37:56 -03:00
Victor Calazans
86258efcbd Remove messages
Remove messages
2025-05-17 09:17:43 -03:00
Davidson Gomes
24d7950d13
Merge pull request #14 from Danielpeter-99/patch-1
Update docker-compose.yml image to correct image
2025-05-17 07:13:02 -03:00
Arley Daniel Peter
f000a08701
Update docker-compose.yml image to correct image
Updating image to correct image https://hub.docker.com/r/evoapicloud/evo-ai/tags on latest
2025-05-16 22:48:35 -03:00
Victor Calazans
257d50a584 feat: Create delay node 2025-05-16 22:25:39 -03:00
Davidson Gomes
9f176bf0e0 Merge tag '0.0.11' into develop
v
2025-05-16 12:15:49 -03:00
Davidson Gomes
21e67e43a3 Merge branch 'release/0.0.11' 2025-05-16 12:15:48 -03:00
Davidson Gomes
c916b7a660 chore(changelog): update changelog for version 0.0.11 with service fixes 2025-05-16 12:15:41 -03:00
Davidson Gomes
c6916eabc5
Merge pull request #9 from Rodribm10/patch-2
Update docker-compose.yml
2025-05-16 09:51:19 -03:00
Davidson Gomes
7f35a9a6bc
Merge pull request #10 from oriondesign2015/develop
⚙️ Fix: importação, seeders automáticos e verificação por e-mail
2025-05-16 09:50:36 -03:00
OrionDesign
6d7b1194d0 ✉️ Verificação manual por e-mail
### 📋 Descrição

Esta PR modifica o comportamento padrão de verificação de usuários no sistema, alterando o parâmetro `auto_verify` de `True` para `False` durante a criação de novos usuários.

####  Alteração específica

- Aplica-se ao serviço `client_service`.
- O valor `auto_verify=True` foi alterado para `False`.

---

### 🚀 Motivação

Atualmente, as contas são aprovadas automaticamente sem que o usuário defina uma senha, o que obriga o uso da funcionalidade de "Esqueci minha senha" para definir uma senha e acessar o painel. Esta PR tem como objetivo permitir que o usuário verifique a conta via e-mail, o que ativa a conta e permite que ele defina a senha de forma imediata, melhorando a experiência do usuario.

Objetivos principais:

- **Maior segurança:** Forçar a verificação por e-mail de novos usuários, garantindo que apenas contas confirmadas possam acessar o sistema.
- **Melhoria na experiência do usuário:** Eliminar a dependência do fluxo de "Esqueci minha senha" logo após o cadastro pelo administrador.

---

### 💥 Impacto

- Será necessário que o usuário confirme o cadastro via e-mail para ativar a conta e definir a senha.
- O processo de onboarding de novos usuários se torna mais controlado e seguro.

---

###  Testes realizados

- Verificado que novos usuários são criados com status de **não verificado**.
- Confirmado que apenas após a confirmação via e-mail o usuário pode acessar e definir a senha.
- Testado o **fluxo completo de registro e verificação manual por e-mail**.

---

### 📝 Observações

- Esta alteração **não afeta usuários já existentes** no sistema.
- O administrador pode cadastrar um novo usuário informando apenas nome e e-mail.
- O próprio usuário deve confirmar a conta por e-mail para poder criar a senha e acessar o sistema.
2025-05-16 03:44:23 -03:00
OrionDesign
1bcd76595c ⚙️ Fix: importação e seeders automáticos
### 📋 Descrição

Esta PR aborda dois pontos críticos identificados durante a execução do container da aplicação:

####  Correção de importação no `email_service.py`

- Corrigido o caminho da importação:
  - **De:** `from config.settings import settings`
  - **Para:** `from src.config.settings import settings`
- Essa alteração soluciona o erro `ModuleNotFoundError: No module named 'config'`, que impedia a inicialização da aplicação.

####  Execução automática dos seeders via Dockerfile

- Adicionado o script de seeders à sequência de inicialização da aplicação no Dockerfile.
- O comando de inicialização foi alterado para:
  ```bash
  alembic upgrade head && python -m scripts.run_seeders && uvicorn src.main:app --host $HOST --port $PORT
  ```
- Isso garante que os seeders (incluindo o usuário admin) sejam executados automaticamente após as migrações.

---

### 💥 Impacto

- Corrige o erro de importação, permitindo que a aplicação seja iniciada corretamente.
- Automatiza a criação do usuário admin e outros dados iniciais essenciais.
- Melhora a experiência de primeira execução, eliminando etapas manuais.

---

###  Testes realizados

- Verificado que a aplicação inicia corretamente após as alterações.
- Confirmado que os seeders são executados com sucesso, criando o usuário admin e outros dados iniciais conforme esperado.

---

### 📝 Observações

- O novo caminho de importação em `email_service.py` está alinhado com o padrão utilizado nos demais arquivos do projeto.
- Os seeders são executados somente após a conclusão bem-sucedida das migrações do banco de dados.
2025-05-16 01:51:47 -03:00
Rodribm10
6234e3838f
Update docker-compose.yml 2025-05-15 20:39:14 -03:00
Davidson Gomes
ddb7650f59 Merge tag '0.0.10' into develop
v
2025-05-15 19:31:12 -03:00
Davidson Gomes
43b2c03c91 Merge branch 'release/0.0.10' 2025-05-15 19:31:10 -03:00
Davidson Gomes
eebe995826
Merge pull request #8 from gomessguii/develop
chore(workflows): simplify Docker image workflow and update job struc…
2025-05-15 19:28:51 -03:00
Guilherme Gomes
5e854b9d1d chore(workflows): simplify Docker image workflow and update job structure 2025-05-15 19:27:34 -03:00
Davidson Gomes
0c96e42f51 Merge tag '0.0.10' into develop
v
2025-05-15 19:22:22 -03:00
Davidson Gomes
2d98cb715f Merge branch 'release/0.0.10' 2025-05-15 19:22:19 -03:00
Davidson Gomes
16df8a9a1b chore(docker): add image tag for API service in docker-compose 2025-05-15 19:22:08 -03:00
Davidson Gomes
ecdd0cc917
Merge pull request #7 from gomessguii/develop
chore(workflows): add support for tagging events in Docker image work…
2025-05-15 19:21:25 -03:00
Guilherme Gomes
f4878452f6 chore(workflows): add support for tagging events in Docker image workflow 2025-05-15 19:20:54 -03:00
Davidson Gomes
8654988f57
Merge pull request #6 from gomessguii/develop
chore(workflows): update Docker Hub image name in Docker image workflow
2025-05-15 19:17:04 -03:00
Guilherme Gomes
09eea12250 chore(workflows): update Docker Hub image name in Docker image workflow 2025-05-15 19:16:21 -03:00
Davidson Gomes
07d4fb18f3 Merge tag '0.0.10' into develop
v
2025-05-15 19:13:23 -03:00
Davidson Gomes
0b12acd4ac Merge branch 'release/0.0.10' 2025-05-15 19:13:21 -03:00
Davidson Gomes
a33881759c
Merge pull request #5 from gomessguii/develop
chore(workflows): update Docker image workflow to support Docker Hub
2025-05-15 19:12:54 -03:00
Guilherme Gomes
139497e1fa
Merge branch 'EvolutionAPI:develop' into develop 2025-05-15 19:10:49 -03:00
Davidson Gomes
2bbe2c90ac feat(email): add SMTP email provider support as alternative to SendGrid 2025-05-15 19:09:18 -03:00
Guilherme Gomes
e200ce6490 chore(workflows): update Docker image workflow to support Docker Hub 2025-05-15 19:09:05 -03:00
Davidson Gomes
13b68095e6
Merge pull request #4 from oriondesign2015/develop
Suporte ao provedor SMTP
2025-05-15 19:07:29 -03:00
Davidson Gomes
7940876e6f Merge tag '0.0.10' into develop
v
2025-05-15 19:01:20 -03:00
Davidson Gomes
17d72238c7 Merge branch 'release/0.0.10' 2025-05-15 19:01:18 -03:00
Davidson Gomes
22a771abd8 chore(changelog): update version date for release 0.0.10 2025-05-15 19:01:08 -03:00
Davidson Gomes
1656fda8da refactor(a2a_task_manager): enhance logging and file handling in streaming task processing 2025-05-14 22:21:58 -03:00
Davidson Gomes
6bf0ea52e0 feat(a2a): add file support and multimodal content processing for A2A protocol 2025-05-14 22:15:08 -03:00
OrionDesign
add128f4d5 Suporte ao provedor SMTP
Adiciona suporte ao envio de e-mails via protocolo SMTP, além do já existente provedor SendGrid. Agora é possível selecionar entre "sendgrid" ou "smtp" por meio da variável `EMAIL_PROVIDER`.

Novas variáveis de ambiente:
• EMAIL_PROVIDER="smtp" # ou sendgrid
• SMTP_HOST="your-smtp-host"
• SMTP_FROM="noreply-smtp@yourdomain.com"
• SMTP_USER="your-smtp-username"
• SMTP_PASSWORD="your-smtp-password"
• SMTP_PORT=587
• SMTP_USE_TLS=true
• SMTP_USE_SSL=false
2025-05-14 18:53:21 -03:00
Davidson Gomes
958eeec4a6 fix(docs): correct Model Control Protocol to Model Context Protocol in README 2025-05-14 18:22:15 -03:00
Davidson Gomes
18c6865926 refactor(a2a_agent): remove commented-out code and improve clarity 2025-05-14 15:17:15 -03:00
Davidson Gomes
3622260c11 refactor(agent_service): sanitize agent names and improve agent card fetching 2025-05-14 15:10:48 -03:00
Davidson Gomes
0ca6b4f3e9 refactor(agent): remove CrewAI agent support and update related configurations 2025-05-14 13:13:27 -03:00
Davidson Gomes
2a80bdf7a3 feat(agent): add Task Agent for structured task execution and improve context management 2025-05-14 12:36:34 -03:00
Davidson Gomes
198eb57032 refactor(agent_service): simplify agent configuration validation and remove unnecessary comments 2025-05-14 08:58:00 -03:00
Davidson Gomes
9ab001c35e chore(dependencies): update litellm version constraint to allow minor updates 2025-05-14 08:33:15 -03:00
Davidson Gomes
0dbf6d1c13 feat(agent): add support for CrewAI agents and update related configurations 2025-05-14 08:23:59 -03:00
Davidson Gomes
6cfff4cc95
Merge pull request #3 from EvolutionAPI/develop
chore(changelog): add entry for API key sharing and flexible authentication in version 0.0.9
2025-05-13 19:53:51 -03:00
Davidson Gomes
98c559e1ce chore(changelog): add entry for API key sharing and flexible authentication in version 0.0.9 2025-05-13 19:45:34 -03:00
Davidson Gomes
6a9f329def feat(agent): add API key sharing and flexible authentication for chat routes 2025-05-13 19:45:12 -03:00
Davidson Gomes
b29d8d108e refactor(a2a_task_manager): simplify chunk processing and improve error handling 2025-05-13 18:47:15 -03:00
Davidson Gomes
72e4b7865a Merge tag '0.0.9' into develop
v
2025-05-13 18:19:21 -03:00
Davidson Gomes
ef5e84859d Merge branch 'release/0.0.9' 2025-05-13 18:19:19 -03:00
Davidson Gomes
ae62a557d3 chore(changelog): update changelog for version 0.0.9 with release date and user authentication changes 2025-05-13 18:19:12 -03:00
Davidson Gomes
86de80a998 refactor(workflow_agent): iterate over AsyncGenerator in workflow execution 2025-05-13 18:18:06 -03:00
Davidson Gomes
2bac2b3824 chore: update author information and file names in multiple files 2025-05-13 17:50:14 -03:00
Davidson Gomes
3185233233 fix(workflow_agent): increase recursion limit for state streaming 2025-05-13 15:34:38 -03:00
Davidson Gomes
48597bdf30 chore(changelog): update changelog for version 0.0.9 with user authentication enhancements 2025-05-13 09:12:36 -03:00
Davidson Gomes
b53746dd1f feat(auth): enhance user authentication with detailed error handling 2025-05-13 09:11:58 -03:00
Davidson Gomes
ebbbf62df5 Merge tag '0.0.8' into develop
v
2025-05-13 07:32:40 -03:00
Davidson Gomes
d0f40e7d35 Merge branch 'release/0.0.8' 2025-05-13 07:32:39 -03:00
Davidson Gomes
15a4ec7e33 Merge branch 'main' of github.com:EvolutionAPI/evo-ai 2025-05-13 07:32:18 -03:00
Davidson Gomes
6c0dfae9bf Merge tag '0.0.8' into develop
v
2025-05-13 07:32:00 -03:00
Davidson Gomes
48d15a6128 Merge branch 'release/0.0.8' 2025-05-13 07:31:57 -03:00
Davidson Gomes
7ddbce89d0 chore(changelog): update changelog for version 0.0.8 with author information changes 2025-05-13 07:31:46 -03:00
Davidson Gomes
cf07f732c2 chore: update author information in multiple files 2025-05-13 07:30:52 -03:00
Davidson Gomes
897d3dc9ea
Merge pull request #2 from EvolutionAPI/develop
docs: remove license section from README
2025-05-13 07:16:54 -03:00
Davidson Gomes
8438e75dff docs: remove license section from README 2025-05-13 07:11:04 -03:00
Davidson Gomes
a099575620
Merge pull request #1 from EvolutionAPI/develop
Readme Updates
2025-05-13 06:53:49 -03:00
Davidson Gomes
37c17c9e3d docs: update README with API documentation, logs, contributing guidelines, and acknowledgments 2025-05-13 06:52:57 -03:00
Davidson Gomes
7e013787ed Merge tag '0.0.7' into develop
v
2025-05-13 06:45:06 -03:00
Davidson Gomes
984b3b28ba Merge branch 'release/0.0.7' 2025-05-13 06:45:04 -03:00
Davidson Gomes
80f04ada32 chore(changelog): update changelog for version 0.0.7 with Docker CI workflow details 2025-05-13 06:44:57 -03:00
Davidson Gomes
3dd6971fce feat(ci): add Docker image CI workflow for automated builds and pushes 2025-05-13 06:43:04 -03:00
Davidson Gomes
02cbda22dd Merge tag '0.0.6' into develop
v
2025-05-13 06:41:31 -03:00
Davidson Gomes
e3e40ede2b Merge branch 'release/0.0.6' 2025-05-13 06:41:29 -03:00
Davidson Gomes
0c3d2fdbe2 docs: add changelog file to document project updates and version history 2025-05-13 06:41:23 -03:00
Davidson Gomes
6be09de87d docs: add frontend installation instructions to README 2025-05-13 06:40:04 -03:00
Davidson Gomes
1e00887167 Merge tag '0.0.5' into develop
v
2025-05-13 06:35:28 -03:00
Davidson Gomes
705791d17f Merge branch 'release/0.0.5' 2025-05-13 06:35:24 -03:00
Davidson Gomes
0ec9bbdc13 chore: add Apache License 2.0 and update project license information in README and pyproject.toml 2025-05-13 06:35:16 -03:00
Davidson Gomes
146c28ae27 style(templates): adjust color scheme in base email template for better visual appeal 2025-05-12 19:41:07 -03:00
Davidson Gomes
fc61fb062e style(templates): update base email template styles for improved aesthetics 2025-05-12 19:36:43 -03:00
Davidson Gomes
a46402fd08 refactor(auth, email_service, user_service, templates): update email handling and improve base email template styling 2025-05-12 19:23:11 -03:00
Davidson Gomes
47307a1045 Merge tag '0.0.4' into develop
v
2025-05-12 17:51:38 -03:00
Davidson Gomes
b21e355ce1 Merge branch 'release/0.0.4' 2025-05-12 17:51:36 -03:00
Davidson Gomes
0c69df107e refactor(a2a_task_manager): improve JSON handling and error logging for chunk processing 2025-05-12 17:51:27 -03:00
Davidson Gomes
4800807783 docs: add Langfuse integration section to README for tracing and observability 2025-05-12 17:29:13 -03:00
Davidson Gomes
71ecc8f35b Merge tag '0.0.3' into develop
v
2025-05-12 17:20:31 -03:00
Davidson Gomes
782c2aceff Merge branch 'release/0.0.3' 2025-05-12 17:20:29 -03:00
Davidson Gomes
ff27fb157c Merge tag '0.0.2' into develop
v
2025-05-12 17:12:51 -03:00
Davidson Gomes
bafbd494ed Merge branch 'release/0.0.2' 2025-05-12 17:12:48 -03:00
Davidson Gomes
ab1f528a34 feat(otel): integrate OpenTelemetry for Langfuse monitoring and add configuration settings 2025-05-12 17:12:39 -03:00
Davidson Gomes
f319b89806 refactor(chat_routes, agent_runner, workflow_agent): improve JSON handling and clean up code 2025-05-12 16:26:06 -03:00
Davidson Gomes
a1f6b828d5 Merge tag '0.0.1' into develop
v
2025-05-12 13:20:30 -03:00
296 changed files with 52870 additions and 2458 deletions

View File

@ -40,6 +40,12 @@ tests/
.flake8 .flake8
requirements-dev.txt requirements-dev.txt
# Python specific - don't exclude frontend
!frontend/
frontend/node_modules/
frontend/.next/
frontend/dist/
# Ambiente virtual # Ambiente virtual
venv/ venv/
__pycache__/ __pycache__/
@ -54,7 +60,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ # lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/

View File

@ -6,6 +6,9 @@ API_URL="http://localhost:8000"
ORGANIZATION_NAME="Evo AI" ORGANIZATION_NAME="Evo AI"
ORGANIZATION_URL="https://evoai.evoapicloud.com" ORGANIZATION_URL="https://evoai.evoapicloud.com"
# AI Engine configuration: "adk" or "crewai"
AI_ENGINE="adk"
# Database settings # Database settings
POSTGRES_CONNECTION_STRING="postgresql://postgres:root@localhost:5432/evo_ai" POSTGRES_CONNECTION_STRING="postgresql://postgres:root@localhost:5432/evo_ai"
@ -34,11 +37,28 @@ JWT_EXPIRATION_TIME=3600
# Encryption key for API keys # Encryption key for API keys
ENCRYPTION_KEY="your-encryption-key" ENCRYPTION_KEY="your-encryption-key"
# Email provider settings
EMAIL_PROVIDER="sendgrid"
# SendGrid # SendGrid
SENDGRID_API_KEY="your-sendgrid-api-key" SENDGRID_API_KEY="your-sendgrid-api-key"
EMAIL_FROM="noreply@yourdomain.com" EMAIL_FROM="noreply@yourdomain.com"
# SMTP settings
SMTP_HOST="your-smtp-host"
SMTP_FROM="noreply-smtp@yourdomain.com"
SMTP_USER="your-smtp-username"
SMTP_PASSWORD="your-smtp-password"
SMTP_PORT=587
SMTP_USE_TLS=true
SMTP_USE_SSL=false
APP_URL="https://yourdomain.com" APP_URL="https://yourdomain.com"
LANGFUSE_PUBLIC_KEY="your-langfuse-public-key"
LANGFUSE_SECRET_KEY="your-langfuse-secret-key"
OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
# Server settings # Server settings
HOST="0.0.0.0" HOST="0.0.0.0"
PORT=8000 PORT=8000

149
.github/workflows/build-and-deploy.yml vendored Normal file
View File

@ -0,0 +1,149 @@
name: Build and Deploy Docker Images
on:
push:
branches:
- main
tags:
- "*.*.*"
pull_request:
branches:
- main
jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
backend-changed: ${{ steps.changes.outputs.backend }}
frontend-changed: ${{ steps.changes.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
backend:
- 'src/**'
- 'migrations/**'
- 'scripts/**'
- 'Dockerfile'
- 'pyproject.toml'
- 'alembic.ini'
- 'conftest.py'
- 'setup.py'
- 'Makefile'
- '.dockerignore'
frontend:
- 'frontend/**'
build-backend:
name: Build Backend Image
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.backend-changed == 'true' || github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: evoapicloud/evo-ai
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
build-frontend:
name: Build Frontend Image
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend-changed == 'true' || github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: evoapicloud/evo-ai-frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL || 'https://api-evoai.evoapicloud.com' }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

139
.github/workflows/build-homolog.yml vendored Normal file
View File

@ -0,0 +1,139 @@
name: Build Homolog Images
on:
push:
branches:
- develop
- homolog
jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
backend-changed: ${{ steps.changes.outputs.backend }}
frontend-changed: ${{ steps.changes.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
backend:
- 'src/**'
- 'migrations/**'
- 'scripts/**'
- 'Dockerfile'
- 'pyproject.toml'
- 'alembic.ini'
- 'conftest.py'
- 'setup.py'
- 'Makefile'
- '.dockerignore'
frontend:
- 'frontend/**'
build-backend-homolog:
name: Build Backend Homolog
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.backend-changed == 'true' || github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: evoapicloud/evo-ai
tags: |
type=raw,value=homolog
type=raw,value=homolog-{{sha}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
build-frontend-homolog:
name: Build Frontend Homolog
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend-changed == 'true' || github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: evoapicloud/evo-ai-frontend
tags: |
type=raw,value=homolog
type=raw,value=homolog-{{sha}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL_HOMOLOG || 'https://api-homolog-evoai.evoapicloud.com' }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

2
.gitignore vendored
View File

@ -11,7 +11,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ # lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/

101
CHANGELOG.md Normal file
View File

@ -0,0 +1,101 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-05-24
### Added
- Export and Import Agents
### Changed
- A2A implementation updated to version 0.2.1 (https://google.github.io/A2A/specification/#agent2agent-a2a-protocol-specification)
- Frontend redesign
- Fixed message order
## [0.0.11] - 2025-05-16
### Changed
- Fixes in email service and client service
## [0.0.10] - 2025-05-15
### Added
- Add Task Agent for structured single-task execution
- Improve context management in agent execution
- Add file support for A2A protocol (Agent-to-Agent) endpoints
- Implement multimodal content processing in A2A messages
- Add SMTP email provider support as alternative to SendGrid
## [0.0.9] - 2025-05-13
### Added
- Add API key sharing and flexible authentication for chat routes
### Changed
- Enhance user authentication with detailed error handling
## [0.0.8] - 2025-05-13
### Changed
- Update author information in multiple files
## [0.0.7] - 2025-05-13
### Added
- Docker image CI workflow for automated builds and pushes
- GitHub Container Registry (GHCR) integration
- Automated image tagging based on branch and commit
- Docker Buildx setup for multi-platform builds
- Cache optimization for faster builds
- Automated image publishing on push to main and develop branches
## [0.0.6] - 2025-05-13
### Added
- Initial public release of Evo AI platform
- FastAPI-based backend API
- JWT authentication with email verification
- Agent management (LLM, A2A, Sequential, Parallel, Loop, Workflow)
- Agent 2 Agent (A2A) protocol support (Google A2A spec)
- MCP server integration and management
- Custom tools management for agents
- Folder-based agent organization
- Secure API key management with encryption
- PostgreSQL and Redis integration
- Email notifications (SendGrid) with Jinja2 templates
- Audit log system for administrative actions
- LangGraph integration for workflow agents
- OpenTelemetry tracing and Langfuse integration
- Docker and Docker Compose support
- English documentation and codebase
### Changed
- N/A
### Fixed
- N/A
### Security
- JWT tokens with expiration and resource-based access control
- Secure password hashing (bcrypt)
- Account lockout after multiple failed login attempts
- Email verification and password reset flows
---
Older versions and future releases will be listed here.

View File

@ -34,4 +34,4 @@ ENV PORT=8000 \
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
CMD alembic upgrade head && uvicorn src.main:app --host $HOST --port $PORT CMD alembic upgrade head && python -m scripts.run_seeders && uvicorn src.main:app --host $HOST --port $PORT

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Evolution API
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -18,7 +18,7 @@ alembic-downgrade:
# Command to run the server # Command to run the server
run: run:
uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload --env-file .env uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload --env-file .env --reload-exclude frontend/ --reload-exclude "*.log" --reload-exclude "*.tmp"
# Command to run the server in production mode # Command to run the server in production mode
run-prod: run-prod:

1020
README.md

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,32 @@
"""
@author: Davidson Gomes
@file: conftest.py
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
"""
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine

View File

@ -2,19 +2,16 @@ version: "3.8"
services: services:
api: api:
build: . image: evoapicloud/evo-ai:latest
container_name: evo-ai-api
depends_on: depends_on:
postgres: - postgres
condition: service_healthy - redis
redis:
condition: service_healthy
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
POSTGRES_CONNECTION_STRING: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/evo_ai POSTGRES_CONNECTION_STRING: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/evo_ai
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-""} REDIS_PASSWORD: ${REDIS_PASSWORD:-""}
REDIS_SSL: "false" REDIS_SSL: "false"
REDIS_KEY_PREFIX: "a2a:" REDIS_KEY_PREFIX: "a2a:"
@ -28,7 +25,6 @@ services:
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
- ./static:/app/static - ./static:/app/static
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"] test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s interval: 30s
@ -40,12 +36,9 @@ services:
limits: limits:
cpus: "1" cpus: "1"
memory: 1G memory: 1G
networks:
- evo-network
postgres: postgres:
image: postgres:14-alpine image: postgres:14-alpine
container_name: evo-ai-postgres
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
@ -54,15 +47,12 @@ services:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
networks:
- evo-network
deploy: deploy:
resources: resources:
limits: limits:
@ -71,8 +61,12 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
container_name: evo-ai-redis command:
command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} - redis-server
- --appendonly
- "yes"
- --requirepass
- "${REDIS_PASSWORD}"
ports: ports:
- "${REDIS_PORT:-6379}:6379" - "${REDIS_PORT:-6379}:6379"
volumes: volumes:
@ -82,9 +76,6 @@ services:
interval: 5s interval: 5s
timeout: 30s timeout: 30s
retries: 50 retries: 50
restart: unless-stopped
networks:
- evo-network
deploy: deploy:
resources: resources:
limits: limits:
@ -96,8 +87,3 @@ volumes:
name: ${POSTGRES_VOLUME_NAME:-evo-ai-postgres-data} name: ${POSTGRES_VOLUME_NAME:-evo-ai-postgres-data}
redis_data: redis_data:
name: ${REDIS_VOLUME_NAME:-evo-ai-redis-data} name: ${REDIS_VOLUME_NAME:-evo-ai-redis-data}
networks:
evo-network:
name: ${NETWORK_NAME:-evo-network}
driver: bridge

120
frontend/.cursorrules Normal file
View File

@ -0,0 +1,120 @@
# Next.js Project Rules
## Language
- All code, comments, documentation, commits, and PRs MUST be written in English.
## Architecture
### Folder Structure
- `/app`: App router pages and API routes
- Route-specific components should be placed in their respective route folders
- `/components`: Reusable UI components
- `/ui`: Shadcn UI components and their derivatives
- `/contexts`: React Context providers
- `/hooks`: Custom React hooks
- `/lib`: Utility functions and configuration
- `/public`: Static assets
- `/services`: API service functions
- `/styles`: Global styles
- `/types`: TypeScript type definitions
### Component Guidelines
- Use functional components with TypeScript
- Use the `.tsx` extension for React components
- Follow a logical naming convention:
- Complex components: Use PascalCase and create folders with an index.tsx file
- Simple components: Single PascalCase named files
### State Management
- Use React Context for global state
- Use React hooks for local state
- Avoid prop drilling more than 2 levels deep
### API & Data Fetching
- Use API service modules in `/services` directory
- Implement proper error handling and loading states
- Use React Query or SWR for complex data fetching where appropriate
## Development Patterns
### Code Quality
- Maintain type safety - avoid using `any` type
- Write self-documenting code with descriptive names
- Keep components focused on a single responsibility
- Extract complex logic into custom hooks
- Follow DRY (Don't Repeat Yourself) principle
### CSS & Styling
- Use Tailwind CSS for styling
- Use Shadcn UI components as base building blocks
- Maintain consistent spacing and sizing
### Performance
- Avoid unnecessary re-renders
- Optimize images and assets
- Implement code splitting where appropriate
- Use dynamic imports for large components/pages
### Testing
- Write tests for critical business logic
- Test components in isolation
- Implement end-to-end tests for critical user flows
## Git Workflow
### Branch Naming
- Features: `feature/short-description`
- Bugfixes: `fix/short-description`
- Hotfixes: `hotfix/short-description`
- Releases: `release/version`
## Conventions
- Variable and function names in English
- Log and error messages in English
- Documentation in English
- User-facing content (emails, responses) in English
- Indentation with 4 spaces
- Maximum of 79 characters per line
## Commit Rules
- Use Conventional Commits format for all commit messages
- Format: `<type>(<scope>): <description>`
- Types:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation changes
- `style`: Changes that do not affect code meaning (formatting, etc.)
- `refactor`: Code changes that neither fix a bug nor add a feature
- `perf`: Performance improvements
- `test`: Adding or modifying tests
- `chore`: Changes to build process or auxiliary tools
- Scope is optional and should be the module or component affected
- Description should be concise, in the imperative mood, and not capitalized
- Use body for more detailed explanations if needed
- Reference issues in the footer with `Fixes #123` or `Relates to #123`
- Examples:
- `feat(auth): add password reset functionality`
- `fix(api): correct validation error in client registration`
- `docs: update API documentation for new endpoints`
- `refactor(services): improve error handling in authentication`
Format: `type(scope): subject`
Examples:
- `feat(auth): add login form validation`
- `fix(api): resolve user data fetching issue`
- `docs(readme): update installation instructions`
- `style(components): format according to style guide`
### Pull Requests
- Keep PRs focused on a single feature or fix
- Include descriptive titles and descriptions
- Reference related issues
- Request code reviews from appropriate team members
- Ensure CI checks pass before merging
## Code Review Guidelines
- Focus on code quality, architecture, and maintainability
- Provide constructive feedback
- Address all review comments before merging
- Maintain a respectful and collaborative tone

61
frontend/.dockerignore Normal file
View File

@ -0,0 +1,61 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Production builds
.next/
out/
dist/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Logs
logs
*.log
# Temporary files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDEs and editors
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Testing
coverage/
.nyc_output
.coverage
# Other
.cache/

1
frontend/.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8000

31
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Lock files
package-lock.json
yarn.lock
# env files
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

67
frontend/CHANGELOG.md Normal file
View File

@ -0,0 +1,67 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.7] - 2025-05-15
### Added
- Add Task agents
- Add file support for A2A protocol (Agent-to-Agent) endpoints
- Add entrypoint script for dynamic environment variable handling
- Add agent card URL input and copy functionality
## [0.0.6] - 2025-05-13
### Added
- Agent sharing functionality with third parties via API keys
- Dedicated shared-chat page for accessing shared agents
- Local storage mechanism to save recently used shared agents
- Public access to shared agents without full authentication
### Changed
- Add example environment file and update .gitignore
- Add clientId prop to agent-related components and improve agent data processing
- Refactor middleware to handle shared agent routes as public paths
- Update API interceptors to prevent forced logout on shared chat pages
### security
- Implement force logout functionality on 401 Unauthorized responses
## [0.0.5] - 2025-05-13
### Changed
- Update author information in multiple files
## [0.0.4] - 2025-05-13
### Added
- Initial public release
- User-friendly interface for creating and managing AI agents
- Integration with multiple language models (e.g., GPT-4, Claude)
- Client management interface
- Visual configuration for MCP servers
- Custom tools management
- JWT authentication with email verification
- Agent 2 Agent (A2A) protocol support (Google's A2A spec)
- Workflow Agent with ReactFlow for visual workflow creation
- Secure API key management (encrypted storage)
- Agent organization with folders and categories
- Dashboard with agent overview, usage stats, and recent activities
- Agent editor for creating, editing, and configuring agents
- Workflow editor for building and visualizing agent flows
- API key manager for adding, encrypting, and rotating keys
- RESTful API and WebSocket backend integration
- Docker support for containerized deployment
- Complete documentation and contribution guidelines
---
Older versions and future releases will be listed here.

60
frontend/Dockerfile Normal file
View File

@ -0,0 +1,60 @@
# Build stage
FROM node:20.15.1-alpine AS builder
WORKDIR /app
# Define build arguments with default values
ARG NEXT_PUBLIC_API_URL=https://api.evo-ai.co
# Install pnpm globally
RUN npm install -g pnpm
# Copy package files first for better caching
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --no-frozen-lockfile
# Copy all source code (this includes tsconfig.json, lib/, etc.)
COPY . .
# Set environment variables from build arguments
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
# Build the application
RUN pnpm run build
# Production stage
FROM node:20.15.1-alpine AS runner
WORKDIR /app
# Define build arguments again for the runner stage
ARG NEXT_PUBLIC_API_URL=https://api-evoai.evoapicloud.com
# Install pnpm globally
RUN npm install -g pnpm
# Install production dependencies only
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod --no-frozen-lockfile
# Copy built assets from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.mjs ./
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
# Script to replace environment variables at runtime
COPY docker-entrypoint.sh ./
RUN chmod +x ./docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Use entrypoint script to initialize environment variables before starting the app
ENTRYPOINT ["sh", "./docker-entrypoint.sh"]

201
frontend/LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Evolution API
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

237
frontend/README.md Normal file
View File

@ -0,0 +1,237 @@
# Evo AI - AI Agents Platform (Frontend)
Evo AI is an open-source platform for creating and managing AI agents, enabling integration with different AI models and services.
## 🚀 Overview
The Evo AI frontend platform enables:
- User-friendly interface for creating and managing AI agents
- Integration with different language models
- Client management
- Visual configuration of MCP servers
- Custom tools management
- JWT authentication with email verification
- **Agent 2 Agent (A2A) Protocol Support**: Interface for interoperability between AI agents following Google's A2A specification
- **Workflow Agent with ReactFlow**: Visual interface for building complex agent workflows
- **Secure API Key Management**: Interface for encrypted storage of API keys
- **Agent Organization**: Folder structure for organizing agents by categories
## 🧩 Agent Creation Interface
The frontend offers intuitive interfaces for creating different types of agents:
### 1. LLM Agent (Language Model)
Interface for configuring agents based on models like GPT-4, Claude, etc. with tools, MCP servers, and sub-agents.
### 2. A2A Agent (Agent-to-Agent)
Interface for implementing Google's A2A protocol for agent interoperability.
### 3. Sequential Agent
Interface for executing sub-agents in a specific order.
### 4. Parallel Agent
Interface for executing multiple sub-agents simultaneously.
### 5. Loop Agent
Interface for executing sub-agents in a loop with a defined number of iterations.
### 6. Workflow Agent
Visual interface based on ReactFlow for creating complex workflows between agents.
## 🛠️ Technologies
- [Next.js](https://nextjs.org/) - React framework for production
- [React](https://reactjs.org/) - JavaScript library for building user interfaces
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
- [Shadcn UI](https://ui.shadcn.com/) - UI component library
- [Radix UI](https://www.radix-ui.com/) - Unstyled, accessible components
- [TypeScript](https://www.typescriptlang.org/) - Typed JavaScript
- [React Query](https://tanstack.com/query/latest) - Data fetching and state management
- [Zustand](https://zustand-demo.pmnd.rs/) - Global state management
- [React Flow](https://reactflow.dev/) - Library for building node-based visual workflows
- [Axios](https://axios-http.com/) - HTTP client for API communication
## 📋 Requirements
- Node.js 18+ (LTS recommended)
- npm, yarn, or pnpm package manager
- Evo AI backend running
## 🔧 Installation
1. Clone the repository:
```bash
git clone https://github.com/EvolutionAPI/evo-ai-frontend.git
cd evo-ai-frontend
```
2. Install dependencies:
```bash
npm install
# or
yarn install
# or
pnpm install
```
3. Configure environment variables:
```bash
cp .env.example .env
# Edit the .env file with your settings
```
## 🚀 Running the Project
```bash
# Development mode
npm run dev
# or
yarn dev
# or
pnpm dev
# Production build
npm run build
# or
yarn build
# or
pnpm build
# Start production server
npm run start
# or
yarn start
# or
pnpm start
```
The project will be available at [http://localhost:3000](http://localhost:3000)
## 🔐 Authentication
The frontend implements JWT authentication integrated with the backend:
- **User Registration**: Form for creating new accounts
- **Email Verification**: Process for verifying via email
- **Login**: Authentication of existing users
- **Password Recovery**: Complete password recovery flow
- **Secure Storage**: Tokens stored in HttpOnly cookies
## 🖥️ Main Interface Features
### Dashboard
Main dashboard showing:
- Agent overview
- Usage statistics
- Recent activities
- Quick links for agent creation
### Agent Editor
Complete interface for:
- Creating new agents
- Editing existing agents
- Configuring instructions
- Selecting models
- Setting up API keys
### Workflow Editor
Visual editor based on ReactFlow for:
- Creating complex workflows
- Connecting different agents
- Defining conditionals and decision flows
- Visualizing data flow
### API Key Manager
Interface for:
- Adding new API keys
- Securely encrypting keys
- Managing existing keys
- Rotating and updating keys
### Agent Organization
System for:
- Creating folders and categories
- Organizing agents by type or use case
- Searching and filtering agents
## 🔄 Backend Integration
The frontend communicates with the backend through:
- **RESTful API**: Endpoints for resource management
- **WebSockets**: Real-time communication for agent messages
- **Response Streaming**: Support for streaming model responses
## 🐳 Docker Support
The project includes Docker configuration for containerized deployment:
```bash
# Build the Docker image
./docker_build.sh
# or
docker build -t nextjs-frontend .
# Run the container
docker run -p 3000:3000 nextjs-frontend
```
# 🐳 Docker Compose
```bash
# Copy the .env file
cp .env.example .env
# Build and deploy
docker-compose up -d --build
```
## 🤝 Contributing
We welcome contributions from the community! Here's how you can help:
1. Fork the project
2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
3. Make your changes and add tests if possible
4. Run tests and make sure they pass
5. Commit your changes following conventional commits format (`feat: add amazing feature`)
6. Push to the branch (`git push origin feature/AmazingFeature`)
7. Open a Pull Request
Please read our [Contributing Guidelines](CONTRIBUTING.md) for more details.
## 📄 License
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
**Trademark Notice:** The name "Evo AI" and related branding are protected trademarks. Unauthorized use is prohibited.
## 👨‍💻 Development Commands
- `npm run dev` - Start the development server
- `npm run build` - Build the application for production
- `npm run start` - Start the production server
- `npm run lint` - Run ESLint to check code quality
- `npm run format` - Format code with Prettier
## 🙏 Acknowledgments
- [Next.js](https://nextjs.org/)
- [React](https://reactjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Shadcn UI](https://ui.shadcn.com/)
- [ReactFlow](https://reactflow.dev/)

View File

@ -0,0 +1,506 @@
/*
@author: Davidson Gomes
@file: /app/agents/AgentCard.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Folder } from "@/services/agentService";
import { Agent, AgentType } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import {
ArrowRight,
Bot,
BookOpenCheck,
ChevronDown,
ChevronUp,
Code,
ExternalLink,
GitBranch,
MoveRight,
Pencil,
RefreshCw,
Settings,
Share2,
Trash2,
Workflow,
TextSelect,
Download,
FlaskConical,
} from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { exportAsJson } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
onEdit: (agent: Agent) => void;
onDelete: (agent: Agent) => void;
onMove: (agent: Agent) => void;
onShare?: (agent: Agent) => void;
onWorkflow?: (agentId: string) => void;
availableMCPs?: MCPServer[];
getApiKeyNameById?: (id: string | undefined) => string | null;
getAgentNameById?: (id: string) => string;
folders?: Folder[];
agents: Agent[];
}
export function AgentCard({
agent,
onEdit,
onDelete,
onMove,
onShare,
onWorkflow,
availableMCPs = [],
getApiKeyNameById = () => null,
getAgentNameById = (id) => id,
folders = [],
agents,
}: AgentCardProps) {
const [expanded, setExpanded] = useState(false);
const router = useRouter();
const getAgentTypeInfo = (type: AgentType) => {
const types: Record<
string,
{
label: string;
icon: React.ElementType;
color: string;
bgColor: string;
badgeClass: string;
}
> = {
llm: {
label: "LLM Agent",
icon: Code,
color: "#00cc7d",
bgColor: "bg-green-500/10",
badgeClass:
"bg-green-900/30 text-green-400 border-green-600/30 hover:bg-green-900/40",
},
a2a: {
label: "A2A Agent",
icon: ExternalLink,
color: "#6366f1",
bgColor: "bg-indigo-500/10",
badgeClass:
"bg-indigo-900/30 text-indigo-400 border-indigo-600/30 hover:bg-indigo-900/40",
},
sequential: {
label: "Sequential Agent",
icon: ArrowRight,
color: "#f59e0b",
bgColor: "bg-yellow-500/10",
badgeClass:
"bg-yellow-900/30 text-yellow-400 border-yellow-600/30 hover:bg-yellow-900/40",
},
parallel: {
label: "Parallel Agent",
icon: GitBranch,
color: "#8b5cf6",
bgColor: "bg-purple-500/10",
badgeClass:
"bg-purple-900/30 text-purple-400 border-purple-600/30 hover:bg-purple-900/40",
},
loop: {
label: "Loop Agent",
icon: RefreshCw,
color: "#ec4899",
bgColor: "bg-pink-500/10",
badgeClass:
"bg-orange-900/30 text-orange-400 border-orange-600/30 hover:bg-orange-900/40",
},
workflow: {
label: "Workflow Agent",
icon: Workflow,
color: "#3b82f6",
bgColor: "bg-blue-500/10",
badgeClass:
"bg-blue-900/30 text-blue-400 border-blue-700/40 hover:bg-blue-900/40",
},
task: {
label: "Task Agent",
icon: BookOpenCheck,
color: "#ef4444",
bgColor: "bg-red-500/10",
badgeClass:
"bg-red-900/30 text-red-400 border-red-600/30 hover:bg-red-900/40",
},
};
return (
types[type] || {
label: type,
icon: Bot,
color: "#94a3b8",
bgColor: "bg-slate-500/10",
badgeClass:
"bg-slate-900/30 text-slate-400 border-slate-600/30 hover:bg-slate-900/40",
}
);
};
const getAgentTypeIcon = (type: AgentType) => {
const typeInfo = getAgentTypeInfo(type);
const IconComponent = typeInfo.icon;
return (
<IconComponent className="h-5 w-5" style={{ color: typeInfo.color }} />
);
};
const getAgentTypeName = (type: AgentType) => {
return getAgentTypeInfo(type).label;
};
const getAgentTypeBgColor = (type: AgentType) => {
return getAgentTypeInfo(type).bgColor;
};
const getAgentTypeBadgeClass = (type: AgentType) => {
return getAgentTypeInfo(type).badgeClass;
};
const getFolderNameById = (id: string) => {
const folder = folders?.find((f) => f.id === id);
return folder?.name || id;
};
const getTotalTools = () => {
if (agent.type === "llm" && agent.config?.mcp_servers) {
return agent.config.mcp_servers.reduce(
(total, mcp) => total + (mcp.tools?.length || 0),
0
);
}
return 0;
};
const getCreatedAtFormatted = () => {
return new Date(agent.created_at).toLocaleDateString();
};
// Function to export the agent as JSON
const handleExportAgent = () => {
try {
exportAsJson(
agent,
`agent-${agent.name
.replace(/\s+/g, "-")
.toLowerCase()}-${agent.id.substring(0, 8)}`,
true,
agents
);
} catch (error) {
console.error("Error exporting agent:", error);
}
};
// Function to test the A2A agent in the lab
const handleTestA2A = () => {
// Use the agent card URL as base for A2A tests
const agentUrl = agent.agent_card_url?.replace(
"/.well-known/agent.json",
""
);
// Use the API key directly from the agent config
const apiKey = agent.config?.api_key;
// Build the URL with parameters for the lab tests
const params = new URLSearchParams();
if (agentUrl) {
params.set("agent_url", agentUrl);
}
if (apiKey) {
params.set("api_key", apiKey);
}
// Redirect to the lab tests in the "lab" tab
const testUrl = `/documentation?${params.toString()}#lab`;
router.push(testUrl);
};
return (
<Card className="w-full overflow-hidden border border-zinc-800 shadow-lg bg-gradient-to-br from-zinc-800 to-zinc-900">
<div
className={cn(
"p-4 flex justify-between items-center border-b border-zinc-800",
getAgentTypeBgColor(agent.type)
)}
>
<div className="flex items-center gap-2">
{getAgentTypeIcon(agent.type)}
<h3 className="font-medium text-white">{agent.name}</h3>
</div>
<Badge
variant="outline"
className={cn("border", getAgentTypeBadgeClass(agent.type))}
>
{getAgentTypeName(agent.type)}
</Badge>
</div>
<CardContent className="p-0">
<div className="p-4 border-b border-zinc-800">
<p className="text-sm text-zinc-300">
{agent.description && agent.description.length > 100
? `${agent.description.substring(0, 100)}...`
: agent.description}
</p>
</div>
<div
className={cn(
"p-4 flex justify-between items-center",
getAgentTypeBgColor(agent.type),
"bg-opacity-20"
)}
>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Model:</span>
<span className="text-xs font-medium text-zinc-300">
{agent.type === "llm" ? agent.model : "N/A"}
</span>
</div>
<Button
variant="ghost"
size="sm"
className={cn("p-0 h-auto", {
"text-green-400 hover:text-green-300": agent.type === "llm",
"text-indigo-400 hover:text-indigo-300": agent.type === "a2a",
"text-yellow-400 hover:text-yellow-300":
agent.type === "sequential",
"text-purple-400 hover:text-purple-300":
agent.type === "parallel",
"text-orange-400 hover:text-orange-300": agent.type === "loop",
"text-blue-400 hover:text-blue-300": agent.type === "workflow",
"text-red-400 hover:text-red-300": agent.type === "task",
"text-zinc-400 hover:text-white": ![
"llm",
"a2a",
"sequential",
"parallel",
"loop",
"workflow",
"task",
].includes(agent.type),
})}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<span className="ml-1 text-xs">{expanded ? "Less" : "More"}</span>
</Button>
</div>
{expanded && (
<div className="p-4 bg-zinc-950 text-xs space-y-3 animate-in fade-in-50 duration-200">
{agent.folder_id && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Folder:</span>
<Badge
variant="outline"
className={cn(
"h-5 px-2 bg-transparent",
getAgentTypeBadgeClass(agent.type)
)}
>
{getFolderNameById(agent.folder_id)}
</Badge>
</div>
)}
{agent.type === "llm" && agent.api_key_id && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">API Key:</span>
<Badge
variant="outline"
className={cn(
"h-5 px-2 bg-transparent",
getAgentTypeBadgeClass(agent.type)
)}
>
{getApiKeyNameById(agent.api_key_id)}
</Badge>
</div>
)}
{getTotalTools() > 0 && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Tools:</span>
<span className="text-zinc-300">{getTotalTools()}</span>
</div>
)}
{agent.config?.sub_agents && agent.config.sub_agents.length > 0 && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Sub-agents:</span>
<span className="text-zinc-300">
{agent.config.sub_agents.length}
</span>
</div>
)}
{agent.type === "workflow" && agent.config?.workflow && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Elements:</span>
<span className="text-zinc-300">
{agent.config.workflow.nodes?.length || 0} nodes,{" "}
{agent.config.workflow.edges?.length || 0} connections
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-zinc-500">Created at:</span>
<span className="text-zinc-300">{getCreatedAtFormatted()}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-zinc-500">ID:</span>
<span className="text-zinc-300 text-[10px]">{agent.id}</span>
</div>
</div>
)}
<div className="flex border-t border-zinc-800">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex-1 rounded-none h-12 text-zinc-400 hover:text-white hover:bg-zinc-800 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none"
>
<Settings className="h-4 w-4 mr-2" />
Configure
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-zinc-900 border-zinc-700"
side="bottom"
align="end"
>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={handleTestA2A}
>
<FlaskConical className="h-4 w-4 mr-2 text-emerald-400" />
Test A2A
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onEdit(agent)}
>
<Pencil className="h-4 w-4 mr-2 text-emerald-400" />
Edit Agent
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onMove(agent)}
>
<MoveRight className="h-4 w-4 mr-2 text-yellow-400" />
Move Agent
</DropdownMenuItem>
{onWorkflow && agent.type === "workflow" && (
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onWorkflow(agent.id)}
>
<Workflow className="h-4 w-4 mr-2 text-blue-400" />
Open Workflow
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={handleExportAgent}
>
<Download className="h-4 w-4 mr-2 text-purple-400" />
Export as JSON
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onShare(agent)}
>
<Share2 className="h-4 w-4 mr-2 text-blue-400" />
Share Agent
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-500 hover:bg-zinc-800 cursor-pointer"
onClick={() => onDelete(agent)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px bg-zinc-800" />
<a
href={agent.agent_card_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex-1 flex items-center justify-center rounded-none h-12 hover:bg-zinc-800",
{
"text-green-400 hover:text-green-300": agent.type === "llm",
"text-indigo-400 hover:text-indigo-300": agent.type === "a2a",
"text-yellow-400 hover:text-yellow-300":
agent.type === "sequential",
"text-purple-400 hover:text-purple-300":
agent.type === "parallel",
"text-orange-400 hover:text-orange-300": agent.type === "loop",
"text-blue-400 hover:text-blue-300": agent.type === "workflow",
"text-red-400 hover:text-red-300": agent.type === "task",
}
)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Agent Card
</a>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,130 @@
/*
@author: Davidson Gomes
@file: /app/agents/AgentList.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Agent } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import { AgentCard } from "./AgentCard";
import { EmptyState } from "./EmptyState";
import { ApiKey, Folder } from "@/services/agentService";
interface AgentListProps {
agents: Agent[];
isLoading: boolean;
searchTerm: string;
selectedFolderId: string | null;
availableMCPs: MCPServer[];
getApiKeyNameById: (id: string | undefined) => string | null;
getAgentNameById: (id: string) => string;
onEdit: (agent: Agent) => void;
onDelete: (agent: Agent) => void;
onMove: (agent: Agent) => void;
onShare?: (agent: Agent) => void;
onWorkflow?: (agentId: string) => void;
onClearSearch?: () => void;
onCreateAgent?: () => void;
apiKeys: ApiKey[];
folders: Folder[];
}
export function AgentList({
agents,
isLoading,
searchTerm,
selectedFolderId,
availableMCPs,
getApiKeyNameById,
getAgentNameById,
onEdit,
onDelete,
onMove,
onShare,
onWorkflow,
onClearSearch,
onCreateAgent,
apiKeys,
folders,
}: AgentListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-400"></div>
</div>
);
}
if (agents.length === 0) {
if (searchTerm) {
return (
<EmptyState
type="search-no-results"
searchTerm={searchTerm}
onAction={onClearSearch}
/>
);
} else if (selectedFolderId) {
return (
<EmptyState
type="empty-folder"
onAction={onCreateAgent}
actionLabel="Create Agent"
/>
);
} else {
return (
<EmptyState
type="no-agents"
onAction={onCreateAgent}
actionLabel="Create Agent"
/>
);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onEdit={onEdit}
onDelete={onDelete}
onMove={onMove}
onShare={onShare}
onWorkflow={onWorkflow}
availableMCPs={availableMCPs}
getApiKeyNameById={getApiKeyNameById}
getAgentNameById={getAgentNameById}
folders={folders}
agents={agents}
/>
))}
</div>
);
}

View File

@ -0,0 +1,186 @@
/*
@author: Davidson Gomes
@file: /app/agents/AgentSidebar.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Folder,
FolderPlus,
Home,
X,
CircleEllipsis,
Edit,
Trash2,
} from "lucide-react";
interface AgentFolder {
id: string;
name: string;
description: string;
}
interface AgentSidebarProps {
visible: boolean;
folders: AgentFolder[];
selectedFolderId: string | null;
onSelectFolder: (id: string | null) => void;
onAddFolder: () => void;
onEditFolder: (folder: AgentFolder) => void;
onDeleteFolder: (folder: AgentFolder) => void;
onClose: () => void;
}
export function AgentSidebar({
visible,
folders,
selectedFolderId,
onSelectFolder,
onAddFolder,
onEditFolder,
onDeleteFolder,
onClose,
}: AgentSidebarProps) {
return (
<>
{visible && (
<button
onClick={onClose}
className="absolute right-4 top-4 z-40 bg-[#222] p-2 rounded-md text-emerald-400 hover:bg-[#333] hover:text-emerald-400 shadow-md transition-all"
aria-label="Hide folders"
>
<X className="h-5 w-5" />
</button>
)}
<div
className={`fixed top-0 z-30 h-full w-64 bg-[#1a1a1a] p-4 shadow-xl transition-all duration-300 ease-in-out ${
visible ? "left-64 translate-x-0" : "left-0 -translate-x-full pointer-events-none"
}`}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-white flex items-center">
<Folder className="h-5 w-5 mr-2 text-emerald-400" />
Folders
</h2>
<div className="flex space-x-1">
<Button
variant="ghost"
className="h-8 w-8 p-0 text-neutral-400 hover:text-emerald-400 hover:bg-[#222]"
onClick={onAddFolder}
>
<FolderPlus className="h-5 w-5" />
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-neutral-400 hover:text-emerald-400 hover:bg-[#222]"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<div className="space-y-1">
<button
className={`w-full text-left px-3 py-2 rounded-md flex items-center ${
selectedFolderId === null
? "bg-[#333] text-emerald-400"
: "text-neutral-300 hover:bg-[#222] hover:text-white"
}`}
onClick={() => onSelectFolder(null)}
>
<Home className="h-4 w-4 mr-2" />
<span>All agents</span>
</button>
{folders.map((folder) => (
<div key={folder.id} className="flex items-center group">
<button
className={`flex-1 text-left px-3 py-2 rounded-md flex items-center ${
selectedFolderId === folder.id
? "bg-[#333] text-emerald-400"
: "text-neutral-300 hover:bg-[#222] hover:text-white"
}`}
onClick={() => onSelectFolder(folder.id)}
>
<Folder className="h-4 w-4 mr-2" />
<span className="truncate">{folder.name}</span>
</button>
<div className="opacity-0 group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 text-neutral-400 hover:text-white hover:bg-[#222]"
>
<CircleEllipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-[#222] border-[#333] text-white"
>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333] focus:bg-[#333]"
onClick={(e) => {
e.stopPropagation();
onEditFolder(folder);
}}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 hover:bg-[#333] hover:text-red-400 focus:bg-[#333]"
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,96 @@
/*
@author: Davidson Gomes
@file: /app/agents/AgentTypeSelector.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { AgentType } from "@/types/agent";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Code,
ExternalLink,
GitBranch,
RefreshCw,
Workflow,
Users,
BookOpenCheck,
} from "lucide-react";
interface AgentTypeSelectorProps {
value: AgentType;
onValueChange: (value: AgentType) => void;
className?: string;
}
export const agentTypes = [
{ value: "llm", label: "LLM Agent", icon: Code },
{ value: "a2a", label: "A2A Agent", icon: ExternalLink },
{ value: "sequential", label: "Sequential Agent", icon: Workflow },
{ value: "parallel", label: "Parallel Agent", icon: GitBranch },
{ value: "loop", label: "Loop Agent", icon: RefreshCw },
{ value: "workflow", label: "Workflow Agent", icon: Workflow },
{ value: "task", label: "Task Agent", icon: BookOpenCheck },
];
export function AgentTypeSelector({
value,
onValueChange,
className = "",
}: AgentTypeSelectorProps) {
return (
<Select
value={value}
onValueChange={(value: AgentType) => onValueChange(value)}
>
<SelectTrigger
className={`bg-[#222] border-[#444] text-white ${className}`}
>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{agentTypes.map((type) => (
<SelectItem
key={type.value}
value={type.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center gap-2">
<type.icon className="h-4 w-4 text-emerald-400" />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,107 @@
/*
@author: Davidson Gomes
@file: /app/agents/EmptyState.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import { Folder, Plus, Search, Server } from "lucide-react";
interface EmptyStateProps {
type: "no-agents" | "empty-folder" | "search-no-results";
searchTerm?: string;
onAction?: () => void;
actionLabel?: string;
}
export function EmptyState({
type,
searchTerm = "",
onAction,
actionLabel = "Create Agent",
}: EmptyStateProps) {
const getIcon = () => {
switch (type) {
case "empty-folder":
return <Folder className="h-16 w-16 text-emerald-400" />;
case "search-no-results":
return <Search className="h-16 w-16 text-emerald-400" />;
case "no-agents":
default:
return <Server className="h-16 w-16 text-emerald-400" />;
}
};
const getTitle = () => {
switch (type) {
case "empty-folder":
return "Empty folder";
case "search-no-results":
return "No agents found";
case "no-agents":
default:
return "No agents found";
}
};
const getMessage = () => {
switch (type) {
case "empty-folder":
return "This folder is empty. Add agents or create a new one.";
case "search-no-results":
return `We couldn't find any agents that match your search: "${searchTerm}"`;
case "no-agents":
default:
return "You don't have any agents configured. Create your first agent to start!";
}
};
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<div className="mb-6 p-8 rounded-full bg-[#1a1a1a] border border-[#333]">
{getIcon()}
</div>
<h2 className="text-2xl font-semibold text-white mb-3">{getTitle()}</h2>
<p className="text-neutral-300 mb-6 max-w-md">{getMessage()}</p>
{onAction && (
<Button
onClick={onAction}
className={
type === "search-no-results"
? "bg-[#222] text-white hover:bg-[#333]"
: "bg-emerald-400 text-black hover:bg-[#00cc7d] px-6 py-2 hover:shadow-[0_0_15px_rgba(0,255,157,0.2)]"
}
>
{type === "search-no-results" ? null : (
<Plus className="mr-2 h-5 w-5" />
)}
{type === "search-no-results" ? "Clear search" : actionLabel}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,153 @@
/*
@author: Davidson Gomes
@file: /app/agents/SearchInput.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Filter } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
selectedAgentType?: string | null;
onAgentTypeChange?: (type: string | null) => void;
agentTypes?: string[];
}
// Using "all" as a special value to represent no filter
const ANY_TYPE_VALUE = "all";
export function SearchInput({
value,
onChange,
placeholder = "Search agents...",
className = "",
selectedAgentType = null,
onAgentTypeChange,
agentTypes = [],
}: SearchInputProps) {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const handleTypeChange = (value: string) => {
if (onAgentTypeChange) {
onAgentTypeChange(value === ANY_TYPE_VALUE ? null : value);
}
};
return (
<div className={`relative flex items-center gap-2 ${className}`}>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
className="pl-10 w-full bg-[#222] border-[#444] text-white focus:border-emerald-400 focus:ring-emerald-400/10"
/>
{value && (
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white"
onClick={() => onChange("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
{agentTypes.length > 0 && onAgentTypeChange && (
<Popover open={isFilterOpen} onOpenChange={setIsFilterOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`px-3 py-2 bg-[#222] border-[#444] hover:bg-[#333] ${
selectedAgentType ? "text-emerald-400" : "text-neutral-400"
}`}
>
<Filter className="h-4 w-4 mr-1" />
{selectedAgentType ? "Filtered" : "Filter"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2 bg-[#222] border-[#444]">
<div className="space-y-3">
<div className="text-xs font-medium text-neutral-300">
Filter by type
</div>
<Select
value={selectedAgentType ? selectedAgentType : ANY_TYPE_VALUE}
onValueChange={handleTypeChange}
>
<SelectTrigger className="bg-[#333] border-[#444] text-white">
<SelectValue placeholder="Any type" />
</SelectTrigger>
<SelectContent className="bg-[#333] border-[#444] text-white">
<SelectItem value={ANY_TYPE_VALUE}>Any type</SelectItem>
{agentTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedAgentType && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-neutral-300 hover:text-white"
onClick={() => {
onAgentTypeChange(null);
setIsFilterOpen(false);
}}
>
Clear filter
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/A2AAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface A2AAgentConfigProps {
values: {
agent_card_url?: string;
};
onChange: (values: any) => void;
}
export function A2AAgentConfig({ values, onChange }: A2AAgentConfigProps) {
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="agent_card_url" className="text-right text-neutral-300">
Agent Card URL
</Label>
<Input
id="agent_card_url"
value={values.agent_card_url || ""}
onChange={(e) =>
onChange({
...values,
agent_card_url: e.target.value,
})
}
placeholder="https://example.com/.well-known/agent-card.json"
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
<div className="pl-[25%] text-sm text-neutral-400">
<p>
Provide the full URL for the JSON file of the Agent Card that describes
this agent.
</p>
<p className="mt-2">
Agent Cards contain metadata, capabilities descriptions and supported
protocols.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,367 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/LLMAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiKey } from "@/services/agentService";
import { Plus, Maximize2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface LLMAgentConfigProps {
apiKeys: ApiKey[];
availableModels: ModelOption[];
values: {
model?: string;
api_key_id?: string;
instruction?: string;
role?: string;
goal?: string;
};
onChange: (values: any) => void;
onOpenApiKeysDialog: () => void;
}
export function LLMAgentConfig({
apiKeys,
availableModels,
values,
onChange,
onOpenApiKeysDialog,
}: LLMAgentConfigProps) {
const [instructionText, setInstructionText] = useState(values.instruction || "");
const [isInstructionModalOpen, setIsInstructionModalOpen] = useState(false);
const [expandedInstructionText, setExpandedInstructionText] = useState("");
useEffect(() => {
setInstructionText(values.instruction || "");
}, [values.instruction]);
const handleInstructionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInstructionText(newValue);
onChange({
...values,
instruction: newValue,
});
};
const handleExpandInstruction = () => {
setExpandedInstructionText(instructionText);
setIsInstructionModalOpen(true);
};
const handleSaveExpandedInstruction = () => {
setInstructionText(expandedInstructionText);
onChange({
...values,
instruction: expandedInstructionText,
});
setIsInstructionModalOpen(false);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="role" className="text-right text-neutral-300">
Role
</Label>
<div className="col-span-3">
<Input
id="role"
value={values.role || ""}
onChange={(e) =>
onChange({
...values,
role: e.target.value,
})
}
placeholder="Ex: Research Assistant, Customer Support, etc."
className="bg-[#222] border-[#444] text-white"
/>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>Define the role or persona that the agent will assume</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="goal" className="text-right text-neutral-300">
Goal
</Label>
<div className="col-span-3">
<Input
id="goal"
value={values.goal || ""}
onChange={(e) =>
onChange({
...values,
goal: e.target.value,
})
}
placeholder="Ex: Find and organize information, Assist customers with inquiries, etc."
className="bg-[#222] border-[#444] text-white"
/>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>Define the main objective or purpose of this agent</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="api_key" className="text-right text-neutral-300">
API Key
</Label>
<div className="col-span-3 space-y-4">
<div className="flex items-center">
<Select
value={values.api_key_id || ""}
onValueChange={(value) =>
onChange({
...values,
api_key_id: value,
})
}
>
<SelectTrigger className="flex-1 bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select an API key" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{apiKeys.length > 0 ? (
apiKeys
.filter((key) => key.is_active !== false)
.map((key) => (
<SelectItem
key={key.id}
value={key.id}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center">
<span>{key.name}</span>
<Badge className="ml-2 bg-[#333] text-emerald-400 text-xs">
{key.provider}
</Badge>
</div>
</SelectItem>
))
) : (
<div className="text-neutral-500 px-2 py-1.5 pl-8">
No API keys available
</div>
)}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={onOpenApiKeysDialog}
className="ml-2 bg-[#222] text-emerald-400 hover:bg-[#333]"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{apiKeys.length === 0 && (
<div className="flex items-center text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1 text-neutral-400">i</span>
<span>
You need to{" "}
<Button
variant="link"
onClick={onOpenApiKeysDialog}
className="h-auto p-0 text-xs text-emerald-400"
>
register API keys
</Button>{" "}
before creating an agent.
</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model" className="text-right text-neutral-300">
Model
</Label>
<Select
value={values.model}
onValueChange={(value) =>
onChange({
...values,
model: value,
})
}
>
<SelectTrigger className="col-span-3 bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select the model" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white p-0">
<div className="sticky top-0 z-10 p-2 bg-[#222] border-b border-[#444]">
<Input
placeholder="Search models..."
className="bg-[#333] border-[#444] text-white h-8"
onChange={(e) => {
const searchQuery = e.target.value.toLowerCase();
const items = document.querySelectorAll('[data-model-item="true"]');
items.forEach((item) => {
const text = item.textContent?.toLowerCase() || '';
if (text.includes(searchQuery)) {
(item as HTMLElement).style.display = 'flex';
} else {
(item as HTMLElement).style.display = 'none';
}
});
}}
/>
</div>
<div className="max-h-[200px] overflow-y-auto py-1">
{availableModels
.filter((model) => {
if (!values.api_key_id) return true;
const selectedKey = apiKeys.find(
(key) => key.id === values.api_key_id
);
if (!selectedKey) return true;
return model.provider === selectedKey.provider;
})
.map((model) => (
<SelectItem
key={model.value}
value={model.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
data-model-item="true"
>
{model.label}
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="instruction" className="text-right text-neutral-300">
Instructions
</Label>
<div className="col-span-3">
<div className="relative">
<Textarea
id="instruction"
value={instructionText}
onChange={handleInstructionChange}
className="w-full bg-[#222] border-[#444] text-white pr-10"
rows={4}
onClick={handleExpandInstruction}
/>
<button
type="button"
className="absolute top-3 right-5 text-neutral-400 hover:text-emerald-400 focus:outline-none"
onClick={handleExpandInstruction}
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>
Characters like {"{"} and {"}"} or {"{{"} and {"}}"} are automatically escaped to avoid errors in Python.
<span className="ml-2 text-emerald-400">Click to expand editor.</span>
</span>
</div>
</div>
</div>
{/* Expanded Instruction Modal */}
<Dialog open={isInstructionModalOpen} onOpenChange={setIsInstructionModalOpen}>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">Agent Instructions</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-[60vh]">
<Textarea
value={expandedInstructionText}
onChange={(e) => setExpandedInstructionText(e.target.value)}
className="flex-1 min-h-full bg-[#222] border-[#444] text-white p-4 focus:border-emerald-400 focus:ring-emerald-400 focus:ring-opacity-50 resize-none"
placeholder="Enter detailed instructions for the agent..."
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsInstructionModalOpen(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveExpandedInstruction}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save Instructions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,133 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/LoopAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { ArrowRight } from "lucide-react";
interface LoopAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
max_iterations?: number;
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function LoopAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: LoopAgentConfigProps) {
const handleMaxIterationsChange = (value: string) => {
const maxIterations = parseInt(value);
onChange({
...values,
config: {
...values.config,
max_iterations: isNaN(maxIterations) ? undefined : maxIterations,
},
});
};
return (
<div className="space-y-6">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max_iterations" className="text-right text-neutral-300">
Max. Iterations
</Label>
<Input
id="max_iterations"
type="number"
min={1}
max={100}
value={values.config?.max_iterations || ""}
onChange={(e) => handleMaxIterationsChange(e.target.value)}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Execution Order of Agents
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-3">
{values.config.sub_agents.map((agentId, index) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<div className="flex-1">
<div className="font-medium text-white">
{getAgentNameById(agentId)}
</div>
<div className="text-sm text-neutral-400">
Executed on{" "}
<Badge className="bg-[#333] text-emerald-400 border-none">
Position {index + 1}
</Badge>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to define the execution order
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
The agents will be executed sequentially in the order listed above.
The output of each agent will be provided as input to the next
agent in the sequence.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/ParallelAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { GitBranch } from "lucide-react";
interface ParallelAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
aggregation_method?: string;
timeout_seconds?: number;
custom_mcp_servers?: any[];
wait_for_all?: boolean;
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function ParallelAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: ParallelAgentConfigProps) {
const aggregationMethods = [
{ value: "merge", label: "Merge all responses" },
{ value: "first", label: "Use only the first response" },
{ value: "last", label: "Use only the last response" },
{ value: "custom", label: "Custom aggregation" },
];
const handleTimeoutChange = (value: string) => {
const timeout = parseInt(value);
onChange({
...values,
config: {
...values.config,
timeout_seconds: isNaN(timeout) ? undefined : timeout,
},
});
};
return (
<div className="space-y-6">
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Agents in Parallel
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{values.config.sub_agents.map((agentId) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<GitBranch className="h-5 w-5 text-emerald-400" />
<div className="flex-1">
<div className="font-medium text-white truncate">
{getAgentNameById(agentId)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to execute in parallel
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
All listed agents will be executed simultaneously with the same
input. The responses will be aggregated according to the selected
method.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/SequentialAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { ArrowRight } from "lucide-react";
interface SequentialAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
max_iterations?: number;
custom_mcp_servers?: any[];
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function SequentialAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: SequentialAgentConfigProps) {
const handleMaxIterationsChange = (value: string) => {
const maxIterations = parseInt(value);
onChange({
...values,
config: {
...values.config,
max_iterations: isNaN(maxIterations) ? undefined : maxIterations,
},
});
};
return (
<div className="space-y-6">
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Execution Order of Agents
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-3">
{values.config.sub_agents.map((agentId, index) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<div className="flex-1">
<div className="font-medium text-white">
{getAgentNameById(agentId)}
</div>
<div className="text-sm text-neutral-400">
Executed on{" "}
<Badge className="bg-[#333] text-emerald-400 border-none">
Position {index + 1}
</Badge>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to define the execution order
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
The agents will be executed sequentially in the order listed above.
The output of each agent will be provided as input to the next
agent in the sequence.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,801 @@
/*
@author: Davidson Gomes
@file: /app/agents/config/TaskAgentConfig.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Agent, TaskConfig } from "@/types/agent";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Maximize2,
Save,
X,
ArrowDown,
List,
Search,
Edit,
PenTool,
} from "lucide-react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
interface TaskAgentConfigProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
singleTask?: boolean;
}
const getAgentTypeLabel = (type: string): string => {
const typeMap: Record<string, string> = {
llm: "LLM",
a2a: "A2A",
sequential: "Sequential",
parallel: "Parallel",
loop: "Loop",
workflow: "Workflow",
task: "Task",
};
return typeMap[type] || type;
};
const getAgentTypeColor = (type: string): string => {
const colorMap: Record<string, string> = {
llm: "bg-blue-800 text-white",
a2a: "bg-purple-800 text-white",
sequential: "bg-orange-800 text-white",
parallel: "bg-green-800 text-white",
loop: "bg-pink-800 text-white",
workflow: "bg-yellow-800 text-black",
task: "bg-green-800 text-white",
};
return colorMap[type] || "bg-neutral-800 text-white";
};
export function TaskAgentConfig({
values,
onChange,
agents,
getAgentNameById,
singleTask = false,
}: TaskAgentConfigProps) {
const [newTask, setNewTask] = useState<TaskConfig>({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
const [taskAgentSearchQuery, setTaskAgentSearchQuery] = useState<string>("");
const [filteredTaskAgents, setFilteredTaskAgents] = useState<Agent[]>([]);
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
const [expandedDescription, setExpandedDescription] = useState("");
const [editingTaskIndex, setEditingTaskIndex] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [toolSearchQuery, setToolSearchQuery] = useState<string>("");
const [filteredTools, setFilteredTools] = useState<{id: string, name: string}[]>([]);
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
const [tempSelectedTools, setTempSelectedTools] = useState<string[]>([]);
useEffect(() => {
if (isToolsModalOpen) {
if (isEditing && editingTaskIndex !== null && values.config?.tasks) {
setTempSelectedTools(
values.config.tasks[editingTaskIndex]?.enabled_tools || []
);
} else {
setTempSelectedTools([...newTask.enabled_tools || []]);
}
}
}, [isToolsModalOpen]);
const getAvailableTaskAgents = (currentTaskAgentId?: string) =>
agents.filter(
(agent) =>
agent.id !== values.id &&
(!values.config?.tasks?.some((task) => task.agent_id === agent.id) ||
agent.id === currentTaskAgentId)
);
useEffect(() => {
const currentTaskAgentId =
isEditing && editingTaskIndex !== null && values.config?.tasks
? values.config.tasks[editingTaskIndex].agent_id
: undefined;
const availableAgents = getAvailableTaskAgents(currentTaskAgentId);
if (taskAgentSearchQuery.trim() === "") {
setFilteredTaskAgents(availableAgents);
} else {
const query = taskAgentSearchQuery.toLowerCase();
setFilteredTaskAgents(
availableAgents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description?.toLowerCase() || "").includes(query)
)
);
}
}, [
taskAgentSearchQuery,
agents,
values.config?.tasks,
isEditing,
editingTaskIndex,
]);
useEffect(() => {
// Reset editing state when values change externally
if (!isEditing) {
const currentTaskAgentId =
editingTaskIndex !== null && values.config?.tasks
? values.config.tasks[editingTaskIndex]?.agent_id
: undefined;
setFilteredTaskAgents(getAvailableTaskAgents(currentTaskAgentId));
}
}, [agents, values.config?.tasks]);
const getAvailableTools = () => {
if (!values.config?.tasks || values.config.tasks.length === 0) {
return [];
}
const taskAgentIds = values.config.tasks.map(task => task.agent_id);
const toolsList: {id: string, name: string}[] = [];
const toolsMap: Record<string, boolean> = {};
taskAgentIds.forEach(agentId => {
const agent = agents.find(a => a.id === agentId);
if (agent?.type === "llm" && agent.config?.tools) {
agent.config.tools.forEach(tool => {
if (!toolsMap[tool.id]) {
toolsList.push({ id: tool.id, name: tool.id });
toolsMap[tool.id] = true;
}
});
}
if (agent?.type === "llm" && agent.config?.mcp_servers) {
agent.config.mcp_servers.forEach(mcp => {
if (mcp.tools) {
mcp.tools.forEach(toolId => {
if (!toolsMap[toolId]) {
toolsList.push({ id: toolId, name: toolId });
toolsMap[toolId] = true;
}
});
}
});
}
});
return toolsList;
};
useEffect(() => {
const availableTools = getAvailableTools();
if (toolSearchQuery.trim() === "") {
setFilteredTools(availableTools);
} else {
const query = toolSearchQuery.toLowerCase();
setFilteredTools(
availableTools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.id.toLowerCase().includes(query)
)
);
}
}, [toolSearchQuery, values.config?.tasks, agents]);
const handleAddTask = () => {
if (!newTask.agent_id || !newTask.description) {
return;
}
if (isEditing && editingTaskIndex !== null) {
const tasks = [...(values.config?.tasks || [])];
tasks[editingTaskIndex] = { ...newTask };
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
setIsEditing(false);
setEditingTaskIndex(null);
} else {
const tasks = [...(values.config?.tasks || [])];
if (singleTask) {
tasks.splice(0, tasks.length, newTask);
} else {
tasks.push(newTask);
}
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
}
setNewTask({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
};
const handleEditTask = (index: number) => {
const task = values.config?.tasks?.[index];
if (task) {
setNewTask({ ...task });
setIsEditing(true);
setEditingTaskIndex(index);
}
};
const handleCancelEdit = () => {
setNewTask({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
setIsEditing(false);
setEditingTaskIndex(null);
};
const handleRemoveTask = (index: number) => {
if (editingTaskIndex === index) {
handleCancelEdit();
}
const tasks = [...(values.config?.tasks || [])];
tasks.splice(index, 1);
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
};
const handleDescriptionChange = (
e: React.ChangeEvent<HTMLTextAreaElement>
) => {
const newValue = e.target.value;
setNewTask({
...newTask,
description: newValue,
});
};
const handleExpandDescription = () => {
setExpandedDescription(newTask.description);
setIsDescriptionModalOpen(true);
};
const handleSaveExpandedDescription = () => {
setNewTask({
...newTask,
description: expandedDescription,
});
setIsDescriptionModalOpen(false);
};
const handleToggleTool = (toolId: string) => {
const index = tempSelectedTools.indexOf(toolId);
if (index > -1) {
setTempSelectedTools(tempSelectedTools.filter(id => id !== toolId));
} else {
setTempSelectedTools([...tempSelectedTools, toolId]);
}
};
const isToolEnabled = (toolId: string) => {
return tempSelectedTools.includes(toolId);
};
const handleSaveTools = () => {
if (isEditing && editingTaskIndex !== null && values.config?.tasks) {
const tasks = [...(values.config?.tasks || [])];
const updatedTask = {
...tasks[editingTaskIndex],
enabled_tools: [...tempSelectedTools]
};
tasks[editingTaskIndex] = updatedTask;
const newConfig = {
...(values.config || {}),
tasks: tasks
};
onChange({
...values,
config: newConfig
});
} else if (newTask.agent_id) {
const updatedNewTask = {
...newTask,
enabled_tools: [...tempSelectedTools]
};
setNewTask(updatedNewTask);
}
setIsToolsModalOpen(false);
};
const renderAgentTypeBadge = (agentId: string) => {
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
return null;
}
return (
<Badge className={`ml-2 ${getAgentTypeColor(agent.type)} text-xs`}>
{getAgentTypeLabel(agent.type)}
</Badge>
);
};
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-white flex items-center">
<List className="mr-2 h-5 w-5 text-emerald-400" />
{singleTask ? "Task" : "Tasks"}
</h3>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
{singleTask
? "Configure the task that will be executed by the agent."
: "Configure the sequential tasks that will be executed by the team of agents."}
</p>
{values.config?.tasks && values.config.tasks.length > 0 ? (
<div className="space-y-4 mb-4">
{values.config.tasks.map((task, index) => (
<div
key={index}
className={`border border-[#333] rounded-md p-3 ${
editingTaskIndex === index ? "bg-[#1e3a3a]" : "bg-[#2a2a2a]"
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center">
<span className="inline-flex items-center justify-center rounded-full bg-[#333] px-2 py-1 text-xs text-white mr-2">
{index + 1}
</span>
<h4 className="font-medium text-white flex items-center">
{getAgentNameById(task.agent_id)}
{renderAgentTypeBadge(task.agent_id)}
</h4>
</div>
<p className="text-sm text-neutral-300 mt-1">
{task.description}
</p>
{task.expected_output && (
<div className="mt-2">
<span className="text-xs text-neutral-400">
Expected output:
</span>
<Badge
variant="outline"
className="ml-2 bg-[#333] text-emerald-400 border-emerald-400/30"
>
{task.expected_output}
</Badge>
</div>
)}
{task.enabled_tools && task.enabled_tools.length > 0 && (
<div className="mt-2">
<span className="text-xs text-neutral-400">
Enabled tools:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{task.enabled_tools.map((toolId) => (
<Badge
key={toolId}
className="bg-[#333] text-emerald-400 border border-emerald-400/30 text-xs"
>
{toolId}
</Badge>
))}
</div>
</div>
)}
</div>
<div className="flex">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTask(index)}
className="text-neutral-400 hover:text-emerald-400 hover:bg-[#333] mr-1"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(index)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{!singleTask &&
index < (values.config?.tasks?.length || 0) - 1 && (
<div className="flex justify-center my-2">
<ArrowDown className="h-4 w-4 text-neutral-400" />
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-4 mb-4 bg-[#2a2a2a] rounded-md">
<p className="text-neutral-400">No tasks configured</p>
<p className="text-xs text-neutral-500">
{singleTask
? "Add a task to define the agent's behavior"
: "Add tasks to define the workflow of the team"}
</p>
</div>
)}
{(!singleTask ||
!values.config?.tasks ||
values.config.tasks.length === 0 ||
isEditing) && (
<div className="space-y-3 border-t border-[#333] pt-4">
<h4 className="text-sm font-medium text-white flex items-center justify-between">
<span>
{isEditing
? "Edit task"
: `Add ${singleTask ? "one" : "new"} task`}
</span>
{isEditing && (
<Button
variant="ghost"
size="sm"
onClick={handleCancelEdit}
className="text-neutral-400 hover:text-emerald-400 hover:bg-[#333]"
>
<X className="h-4 w-4 mr-1" /> Cancel
</Button>
)}
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<Label
htmlFor="agent_id"
className="text-xs text-neutral-400 mb-1 block"
>
Agent
</Label>
<Select
value={newTask.agent_id}
onValueChange={(value) =>
setNewTask({ ...newTask, agent_id: value })
}
>
<SelectTrigger className="bg-[#2a2a2a] border-[#444] text-white">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent className="bg-[#2a2a2a] border-[#444] text-white p-0">
<div className="sticky top-0 z-10 p-2 bg-[#2a2a2a] border-b border-[#444]">
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
placeholder="Search agents..."
className="bg-[#333] border-[#444] text-white h-8 pl-8"
value={taskAgentSearchQuery}
onChange={(e) =>
setTaskAgentSearchQuery(e.target.value)
}
/>
</div>
</div>
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredTaskAgents.length > 0 ? (
filteredTaskAgents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="hover:bg-[#333] focus:bg-[#333] flex items-center justify-between px-2"
data-agent-item="true"
>
<div className="flex items-center">
<span className="mr-2">{agent.name}</span>
<Badge
className={`${getAgentTypeColor(
agent.type
)} text-xs`}
>
{getAgentTypeLabel(agent.type)}
</Badge>
</div>
</SelectItem>
))
) : (
<div className="text-neutral-500 px-4 py-2 text-center">
No agents found
</div>
)}
</div>
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<Label
htmlFor="description"
className="text-xs text-neutral-400 mb-1 block"
>
Task description
</Label>
<div className="relative">
<Textarea
id="description"
value={newTask.description}
onChange={handleDescriptionChange}
className="w-full bg-[#2a2a2a] border-[#444] text-white pr-10"
rows={3}
onClick={handleExpandDescription}
/>
<button
type="button"
className="absolute top-3 right-5 text-neutral-400 hover:text-emerald-400 focus:outline-none"
onClick={handleExpandDescription}
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>
Use {"{"}content{"}"} to insert the user's input.
<span className="ml-2 text-emerald-400">
Click to expand editor.
</span>
</span>
</div>
</div>
</div>
<div>
<Label
htmlFor="expected_output"
className="text-xs text-neutral-400 mb-1 block"
>
Expected output (optional)
</Label>
<Input
id="expected_output"
placeholder="Ex: JSON report, List of recommendations, etc."
value={newTask.expected_output}
onChange={(e) =>
setNewTask({ ...newTask, expected_output: e.target.value })
}
className="bg-[#2a2a2a] border-[#444] text-white"
/>
</div>
{newTask.enabled_tools && newTask.enabled_tools.length > 0 && (
<div className="mt-3">
<Label className="text-xs text-neutral-400 mb-1 block">
Selected tools:
</Label>
<div className="flex flex-wrap gap-1">
{newTask.enabled_tools.map((toolId) => (
<Badge
key={toolId}
className="bg-[#333] text-emerald-400 border border-emerald-400/30"
>
{toolId}
</Badge>
))}
</div>
</div>
)}
<div className="flex items-center justify-between mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
if (newTask.agent_id) setIsToolsModalOpen(true);
}}
disabled={!newTask.agent_id}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 px-3"
>
<PenTool className="h-4 w-4 mr-2" />
Configure tools
</Button>
<Button
onClick={handleAddTask}
disabled={!newTask.agent_id || !newTask.description}
className="bg-[#222] text-emerald-400 border border-emerald-400 hover:bg-emerald-400/10"
>
<Save className="h-4 w-4 mr-1" />{" "}
{isEditing ? "Update task" : "Add task"}
</Button>
</div>
</div>
)}
</div>
</div>
<Dialog
open={isDescriptionModalOpen}
onOpenChange={setIsDescriptionModalOpen}
>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">Task Description</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-[60vh]">
<Textarea
value={expandedDescription}
onChange={(e) => setExpandedDescription(e.target.value)}
className="flex-1 min-h-full bg-[#222] border-[#444] text-white p-4 focus:border-emerald-400 focus:ring-emerald-400 focus:ring-opacity-50 resize-none"
placeholder="Enter detailed description for the task..."
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDescriptionModalOpen(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveExpandedDescription}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save description
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isToolsModalOpen} onOpenChange={setIsToolsModalOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">
Available tools
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
placeholder="Search tools..."
className="bg-[#222] border-[#444] text-white pl-9"
value={toolSearchQuery}
onChange={(e) => setToolSearchQuery(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{filteredTools.length > 0 ? (
filteredTools.map((tool) => (
<div
key={tool.id}
className="flex items-center space-x-2 p-2 rounded-md hover:bg-[#333] transition duration-150"
>
<Checkbox
id={tool.id}
checked={isToolEnabled(tool.id)}
onCheckedChange={() => handleToggleTool(tool.id)}
className="border-[#444] data-[state=checked]:bg-emerald-400 data-[state=checked]:text-black"
/>
<Label
htmlFor={tool.id}
className="cursor-pointer text-white flex-1"
>
{tool.name}
</Label>
<Badge className="bg-[#333] text-emerald-400">{tool.id}</Badge>
</div>
))
) : (
<div className="text-center py-8">
<p className="text-neutral-400">No tools available</p>
<p className="text-xs text-neutral-500">
The tools are obtained from the selected agents in the tasks.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleSaveTools}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,184 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/AgentToolDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Agent } from "@/types/agent";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { listAgents } from "@/services/agentService";
import { Loader2 } from "lucide-react";
interface AgentToolDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (tool: { id: string; envs: Record<string, string> }) => void;
currentAgentId?: string;
folderId?: string | null;
clientId: string;
}
export function AgentToolDialog({
open,
onOpenChange,
onSave,
currentAgentId,
folderId,
clientId,
}: AgentToolDialogProps) {
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [search, setSearch] = useState("");
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (open) {
setSelectedAgentId("");
setSearch("");
loadAgents();
}
}, [open, folderId, clientId]);
const loadAgents = async () => {
if (!clientId) return;
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
folderId || undefined
);
// Filter out the current agent to avoid self-reference
const filteredAgents = res.data.filter(agent =>
agent.id !== currentAgentId
);
setAgents(filteredAgents);
} catch (error) {
console.error("Error loading agents:", error);
} finally {
setIsLoading(false);
}
};
const handleSave = () => {
if (!selectedAgentId) return;
onSave({ id: selectedAgentId, envs: {} });
onOpenChange(false);
};
const filteredAgents = agents.filter((agent) =>
agent.name.toLowerCase().includes(search.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">Add Agent Tool</DialogTitle>
<DialogDescription className="text-neutral-400">
Select an agent to add as a tool.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto px-2 pb-2 space-y-4">
<Input
placeholder="Search agent by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 bg-[#222] border-[#444] text-white placeholder:text-neutral-400"
/>
<div className="space-y-2 max-h-60 overflow-y-auto pr-1">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-6">
<Loader2 className="h-6 w-6 text-emerald-400 animate-spin" />
<div className="mt-2 text-sm text-neutral-400">Loading agents...</div>
</div>
) : filteredAgents.length === 0 ? (
<div className="text-neutral-400 text-sm text-center py-6">
{search ? `No agents found matching "${search}"` : "No agents found in this folder."}
</div>
) : (
filteredAgents.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => setSelectedAgentId(agent.id)}
className={cn(
"w-full flex items-start gap-3 p-3 rounded-md border border-[#333] bg-[#232323] hover:bg-[#222] transition text-left cursor-pointer",
selectedAgentId === agent.id && "border-emerald-400 bg-[#1a1a1a] shadow-md"
)}
>
<div className="flex-1">
<div className="font-medium text-white text-base">{agent.name}</div>
<div className="text-xs text-neutral-400 mt-1">
{agent.description || "No description"}
</div>
<div className="text-[10px] text-neutral-500 mt-1">ID: {agent.id}</div>
</div>
{selectedAgentId === agent.id && (
<span className="ml-2 text-emerald-400 font-bold">Selected</span>
)}
</button>
))
)}
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!selectedAgentId || isLoading}
>
Add Tool
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,445 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/ApiKeysDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfirmationDialog } from "./ConfirmationDialog";
import { ApiKey } from "@/services/agentService";
import { Edit, Eye, Key, Plus, Trash2, X } from "lucide-react";
import { useState, useEffect } from "react";
import { availableModelProviders } from "@/types/aiModels";
interface ApiKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
apiKeys: ApiKey[];
isLoading: boolean;
onAddApiKey: (apiKey: {
name: string;
provider: string;
key_value: string;
}) => Promise<void>;
onUpdateApiKey: (
id: string,
apiKey: {
name: string;
provider: string;
key_value?: string;
is_active: boolean;
}
) => Promise<void>;
onDeleteApiKey: (id: string) => Promise<void>;
}
export function ApiKeysDialog({
open,
onOpenChange,
apiKeys,
isLoading,
onAddApiKey,
onUpdateApiKey,
onDeleteApiKey,
}: ApiKeysDialogProps) {
const [isAddingApiKey, setIsAddingApiKey] = useState(false);
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
const [currentApiKey, setCurrentApiKey] = useState<
Partial<ApiKey & { key_value?: string }>
>({});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKey | null>(null);
const [isApiKeyVisible, setIsApiKeyVisible] = useState(false);
const handleAddClick = () => {
setCurrentApiKey({});
setIsAddingApiKey(true);
setIsEditingApiKey(false);
};
const handleEditClick = (apiKey: ApiKey) => {
setCurrentApiKey({ ...apiKey, key_value: "" });
setIsAddingApiKey(true);
setIsEditingApiKey(true);
};
const handleDeleteClick = (apiKey: ApiKey) => {
setApiKeyToDelete(apiKey);
setIsDeleteDialogOpen(true);
};
const handleSaveApiKey = async () => {
if (
!currentApiKey.name ||
!currentApiKey.provider ||
(!isEditingApiKey && !currentApiKey.key_value)
) {
return;
}
try {
if (currentApiKey.id) {
await onUpdateApiKey(currentApiKey.id, {
name: currentApiKey.name,
provider: currentApiKey.provider,
key_value: currentApiKey.key_value,
is_active: currentApiKey.is_active !== false,
});
} else {
await onAddApiKey({
name: currentApiKey.name,
provider: currentApiKey.provider,
key_value: currentApiKey.key_value!,
});
}
setCurrentApiKey({});
setIsAddingApiKey(false);
setIsEditingApiKey(false);
} catch (error) {
console.error("Error saving API key:", error);
}
};
const handleDeleteConfirm = async () => {
if (!apiKeyToDelete) return;
try {
await onDeleteApiKey(apiKeyToDelete.id);
setApiKeyToDelete(null);
setIsDeleteDialogOpen(false);
} catch (error) {
console.error("Error deleting API key:", error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">Manage API Keys</DialogTitle>
<DialogDescription className="text-neutral-400">
Add and manage API keys for use in your agents
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-1">
{isAddingApiKey ? (
<div className="space-y-4 p-4 bg-[#222] rounded-md">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">
{isEditingApiKey ? "Edit Key" : "New Key"}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsAddingApiKey(false);
setIsEditingApiKey(false);
setCurrentApiKey({});
}}
className="text-neutral-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right text-neutral-300">
Name
</Label>
<Input
id="name"
value={currentApiKey.name || ""}
onChange={(e) =>
setCurrentApiKey({
...currentApiKey,
name: e.target.value,
})
}
className="col-span-3 bg-[#333] border-[#444] text-white"
placeholder="OpenAI GPT-4"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="provider"
className="text-right text-neutral-300"
>
Provider
</Label>
<Select
value={currentApiKey.provider}
onValueChange={(value) =>
setCurrentApiKey({
...currentApiKey,
provider: value,
})
}
>
<SelectTrigger className="col-span-3 bg-[#333] border-[#444] text-white">
<SelectValue placeholder="Select Provider" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{availableModelProviders.map((provider) => (
<SelectItem
key={provider.value}
value={provider.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="key_value"
className="text-right text-neutral-300"
>
Key Value
</Label>
<div className="col-span-3 relative">
<Input
id="key_value"
value={currentApiKey.key_value || ""}
onChange={(e) =>
setCurrentApiKey({
...currentApiKey,
key_value: e.target.value,
})
}
className="bg-[#333] border-[#444] text-white pr-10"
type={isApiKeyVisible ? "text" : "password"}
placeholder={
isEditingApiKey
? "Leave blank to keep the current value"
: "sk-..."
}
/>
<Button
variant="ghost"
size="sm"
type="button"
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 text-neutral-400 hover:text-white"
onClick={() => setIsApiKeyVisible(!isApiKeyVisible)}
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
{isEditingApiKey && (
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="is_active"
className="text-right text-neutral-300"
>
Status
</Label>
<div className="col-span-3 flex items-center">
<Checkbox
id="is_active"
checked={currentApiKey.is_active !== false}
onCheckedChange={(checked) =>
setCurrentApiKey({
...currentApiKey,
is_active: !!checked,
})
}
className="mr-2 data-[state=checked]:bg-emerald-400 data-[state=checked]:border-emerald-400"
/>
<Label htmlFor="is_active" className="text-neutral-300">
Active
</Label>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={() => {
setIsAddingApiKey(false);
setIsEditingApiKey(false);
setCurrentApiKey({});
}}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveApiKey}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading && (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-1"></div>
)}
{isEditingApiKey ? "Update" : "Add"}
</Button>
</div>
</div>
) : (
<>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-white">
Available Keys
</h3>
<Button
onClick={handleAddClick}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Plus className="mr-2 h-4 w-4" />
New Key
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-emerald-400"></div>
</div>
) : apiKeys.length > 0 ? (
<div className="space-y-2">
{apiKeys.map((apiKey) => (
<div
key={apiKey.id}
className="flex items-center justify-between p-3 bg-[#222] rounded-md border border-[#333] hover:border-emerald-400/30"
>
<div>
<p className="font-medium text-white">{apiKey.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="bg-[#333] text-emerald-400 border-emerald-400/30"
>
{apiKey.provider.toUpperCase()}
</Badge>
<p className="text-xs text-neutral-400">
Created on{" "}
{new Date(apiKey.created_at).toLocaleDateString()}
</p>
{!apiKey.is_active && (
<Badge
variant="outline"
className="bg-[#333] text-red-400 border-red-400/30"
>
Inactive
</Badge>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClick(apiKey)}
className="text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(apiKey)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10 border border-dashed border-[#333] rounded-md bg-[#222] text-neutral-400">
<Key className="mx-auto h-10 w-10 text-neutral-500 mb-3" />
<p>You don't have any API keys registered</p>
<p className="text-sm mt-1">
Add your API keys to use them in your agents
</p>
<Button
onClick={handleAddClick}
className="mt-4 bg-[#333] text-emerald-400 hover:bg-[#444]"
>
<Plus className="mr-2 h-4 w-4" />
Add Key
</Button>
</div>
)}
</>
)}
</div>
<DialogFooter className="border-t border-[#333] pt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Close
</Button>
</DialogFooter>
</DialogContent>
<ConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title="Confirm Delete"
description={`Are you sure you want to delete the key "${apiKeyToDelete?.name}"? This action cannot be undone.`}
confirmText="Delete"
confirmVariant="destructive"
onConfirm={handleDeleteConfirm}
/>
</Dialog>
);
}

View File

@ -0,0 +1,93 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/ConfirmationDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
cancelText?: string;
confirmText?: string;
confirmVariant?: "default" | "destructive";
onConfirm: () => void;
}
export function ConfirmationDialog({
open,
onOpenChange,
title,
description,
cancelText = "Cancel",
confirmText = "Confirm",
confirmVariant = "default",
onConfirm,
}: ConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-400">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className={
confirmVariant === "destructive"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-emerald-400 text-black hover:bg-[#00cc7d]"
}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,237 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/CustomMCPDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { X, Plus } from "lucide-react";
import { useState, useEffect } from "react";
interface CustomMCPHeader {
id: string;
key: string;
value: string;
}
interface CustomMCPServer {
url: string;
headers: Record<string, string>;
}
interface CustomMCPDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (customMCP: CustomMCPServer) => void;
initialCustomMCP?: CustomMCPServer | null;
clientId: string;
}
export function CustomMCPDialog({
open,
onOpenChange,
onSave,
initialCustomMCP = null,
clientId,
}: CustomMCPDialogProps) {
const [customMCP, setCustomMCP] = useState<Partial<CustomMCPServer>>({
url: "",
headers: {},
});
const [headersList, setHeadersList] = useState<CustomMCPHeader[]>([]);
useEffect(() => {
if (open) {
if (initialCustomMCP) {
setCustomMCP(initialCustomMCP);
const headersList = Object.entries(initialCustomMCP.headers || {}).map(
([key, value], index) => ({
id: `header-${index}`,
key,
value,
})
);
setHeadersList(headersList);
} else {
setCustomMCP({ url: "", headers: {} });
setHeadersList([]);
}
}
}, [open, initialCustomMCP]);
const handleAddHeader = () => {
setHeadersList([
...headersList,
{ id: `header-${Date.now()}`, key: "", value: "" },
]);
};
const handleRemoveHeader = (id: string) => {
setHeadersList(headersList.filter((header) => header.id !== id));
};
const handleHeaderChange = (
id: string,
field: "key" | "value",
value: string
) => {
setHeadersList(
headersList.map((header) =>
header.id === id ? { ...header, [field]: value } : header
)
);
};
const handleSave = () => {
if (!customMCP.url) return;
const headersObject: Record<string, string> = {};
headersList.forEach((header) => {
if (header.key.trim()) {
headersObject[header.key] = header.value;
}
});
onSave({
url: customMCP.url,
headers: headersObject,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
Configure Custom MCP
</DialogTitle>
<DialogDescription className="text-neutral-400">
Configure the URL and HTTP headers for the custom MCP.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-mcp-url" className="text-neutral-300">
MCP URL
</Label>
<Input
id="custom-mcp-url"
value={customMCP.url || ""}
onChange={(e) =>
setCustomMCP({
...customMCP,
url: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
placeholder="https://meu-servidor-mcp.com/api"
/>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">HTTP Headers</h3>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
{headersList.map((header) => (
<div
key={header.id}
className="grid grid-cols-5 items-center gap-2 mb-2"
>
<Input
value={header.key}
onChange={(e) =>
handleHeaderChange(header.id, "key", e.target.value)
}
className="col-span-2 bg-[#333] border-[#444] text-white"
placeholder="Header Name"
/>
<Input
value={header.value}
onChange={(e) =>
handleHeaderChange(header.id, "value", e.target.value)
}
className="col-span-2 bg-[#333] border-[#444] text-white"
placeholder="Header Value"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveHeader(header.id)}
className="col-span-1 h-8 text-red-500 hover:text-red-400 hover:bg-[#444]"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddHeader}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Header
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!customMCP.url}
>
{initialCustomMCP?.url ? "Save Custom MCP" : "Add Custom MCP"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,864 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/CustomToolDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
X,
Plus,
Info,
Trash,
Globe,
FileJson,
LayoutList,
Settings,
Database,
Code,
Server,
Wand
} from "lucide-react";
import { useState, useEffect } from "react";
import { HTTPTool, HTTPToolParameter } from "@/types/agent";
import { sanitizeAgentName } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
interface CustomToolDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (tool: HTTPTool) => void;
initialTool?: HTTPTool | null;
}
export function CustomToolDialog({
open,
onOpenChange,
onSave,
initialTool = null,
}: CustomToolDialogProps) {
const [tool, setTool] = useState<Partial<HTTPTool>>({
name: "",
method: "GET",
endpoint: "",
description: "",
headers: {},
values: {},
parameters: {},
error_handling: {
timeout: 30,
retry_count: 0,
fallback_response: {},
},
});
const [headersList, setHeadersList] = useState<{ id: string; key: string; value: string }[]>([]);
const [bodyParams, setBodyParams] = useState<{ id: string; key: string; param: HTTPToolParameter }[]>([]);
const [pathParams, setPathParams] = useState<{ id: string; key: string; desc: string }[]>([]);
const [queryParams, setQueryParams] = useState<{ id: string; key: string; value: string }[]>([]);
const [valuesList, setValuesList] = useState<{ id: string; key: string; value: string }[]>([]);
const [timeout, setTimeout] = useState<number>(30);
const [fallbackError, setFallbackError] = useState<string>("");
const [fallbackMessage, setFallbackMessage] = useState<string>("");
const [activeTab, setActiveTab] = useState("basics");
useEffect(() => {
if (open) {
if (initialTool) {
setTool(initialTool);
setHeadersList(
Object.entries(initialTool.headers || {}).map(([key, value], idx) => ({
id: `header-${idx}`,
key,
value,
}))
);
setBodyParams(
Object.entries(initialTool.parameters?.body_params || {}).map(([key, param], idx) => ({
id: `body-${idx}`,
key,
param,
}))
);
setPathParams(
Object.entries(initialTool.parameters?.path_params || {}).map(([key, desc], idx) => ({
id: `path-${idx}`,
key,
desc: desc as string,
}))
);
setQueryParams(
Object.entries(initialTool.parameters?.query_params || {}).map(([key, value], idx) => ({
id: `query-${idx}`,
key,
value: value as string,
}))
);
setValuesList(
Object.entries(initialTool.values || {}).map(([key, value], idx) => ({
id: `val-${idx}`,
key,
value: value as string,
}))
);
setTimeout(initialTool.error_handling?.timeout || 30);
setFallbackError(initialTool.error_handling?.fallback_response?.error || "");
setFallbackMessage(initialTool.error_handling?.fallback_response?.message || "");
} else {
setTool({
name: "",
method: "GET",
endpoint: "",
description: "",
headers: {},
values: {},
parameters: {},
error_handling: {
timeout: 30,
retry_count: 0,
fallback_response: {},
},
});
setHeadersList([]);
setBodyParams([]);
setPathParams([]);
setQueryParams([]);
setValuesList([]);
setTimeout(30);
setFallbackError("");
setFallbackMessage("");
}
setActiveTab("basics");
}
}, [open, initialTool]);
const handleAddHeader = () => {
setHeadersList([...headersList, { id: `header-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveHeader = (id: string) => {
setHeadersList(headersList.filter((h) => h.id !== id));
};
const handleHeaderChange = (id: string, field: "key" | "value", value: string) => {
setHeadersList(headersList.map((h) => (h.id === id ? { ...h, [field]: value } : h)));
};
const handleAddBodyParam = () => {
setBodyParams([
...bodyParams,
{
id: `body-${Date.now()}`,
key: "",
param: { type: "string", required: false, description: "" },
},
]);
};
const handleRemoveBodyParam = (id: string) => {
setBodyParams(bodyParams.filter((p) => p.id !== id));
};
const handleBodyParamChange = (id: string, field: "key" | keyof HTTPToolParameter, value: string | boolean) => {
setBodyParams(
bodyParams.map((p) =>
p.id === id
? field === "key"
? { ...p, key: value as string }
: { ...p, param: { ...p.param, [field]: value } }
: p
)
);
};
// Path Params
const handleAddPathParam = () => {
setPathParams([...pathParams, { id: `path-${Date.now()}`, key: "", desc: "" }]);
};
const handleRemovePathParam = (id: string) => {
setPathParams(pathParams.filter((p) => p.id !== id));
};
const handlePathParamChange = (id: string, field: "key" | "desc", value: string) => {
setPathParams(pathParams.map((p) => (p.id === id ? { ...p, [field]: value } : p)));
};
// Query Params
const handleAddQueryParam = () => {
setQueryParams([...queryParams, { id: `query-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveQueryParam = (id: string) => {
setQueryParams(queryParams.filter((q) => q.id !== id));
};
const handleQueryParamChange = (id: string, field: "key" | "value", value: string) => {
setQueryParams(queryParams.map((q) => (q.id === id ? { ...q, [field]: value } : q)));
};
// Values
const handleAddValue = () => {
setValuesList([...valuesList, { id: `val-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveValue = (id: string) => {
setValuesList(valuesList.filter((v) => v.id !== id));
};
const handleValueChange = (id: string, field: "key" | "value", value: string) => {
setValuesList(valuesList.map((v) => (v.id === id ? { ...v, [field]: value } : v)));
};
const handleSave = () => {
if (!tool.name || !tool.endpoint) return;
const headersObject: Record<string, string> = {};
headersList.forEach((h) => {
if (h.key.trim()) headersObject[h.key] = h.value;
});
const bodyParamsObject: Record<string, HTTPToolParameter> = {};
bodyParams.forEach((p) => {
if (p.key.trim()) bodyParamsObject[p.key] = p.param;
});
const pathParamsObject: Record<string, string> = {};
pathParams.forEach((p) => {
if (p.key.trim()) pathParamsObject[p.key] = p.desc;
});
const queryParamsObject: Record<string, string> = {};
queryParams.forEach((q) => {
if (q.key.trim()) queryParamsObject[q.key] = q.value;
});
const valuesObject: Record<string, string> = {};
valuesList.forEach((v) => {
if (v.key.trim()) valuesObject[v.key] = v.value;
});
// Sanitize the tool name
const sanitizedName = sanitizeAgentName(tool.name);
onSave({
...(tool as HTTPTool),
name: sanitizedName,
headers: headersObject,
values: valuesObject,
parameters: {
...tool.parameters,
body_params: bodyParamsObject,
path_params: pathParamsObject,
query_params: queryParamsObject,
},
error_handling: {
timeout,
retry_count: tool.error_handling?.retry_count ?? 0,
fallback_response: {
error: fallbackError,
message: fallbackMessage,
},
},
} as HTTPTool);
onOpenChange(false);
};
const ParamField = ({
children,
label,
tooltip
}: {
children: React.ReactNode,
label: string,
tooltip?: string
}) => (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label className="text-sm font-medium text-neutral-200">
{label}
</Label>
{tooltip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-neutral-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="bg-neutral-800 border-neutral-700 text-white p-3 max-w-sm">
<p className="text-xs">{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{children}
</div>
);
const FieldList = <T extends Record<string, any>>({
items,
onAdd,
onRemove,
onChange,
fields,
addText,
emptyText,
icon
}: {
items: T[],
onAdd: () => void,
onRemove: (id: string) => void,
onChange: (id: string, field: string, value: any) => void,
fields: { name: string, field: string, placeholder: string, width: number, type?: string }[],
addText: string,
emptyText: string,
icon: React.ReactNode
}) => (
<div className="border border-neutral-700 rounded-md p-3 bg-neutral-800/50">
{items.length > 0 ? (
<div className="space-y-2 mb-3">
{items.map((item) => (
<div key={item.id} className="flex items-center gap-2 group">
<div className="flex-1 flex gap-2 w-full">
{fields.map(field => {
// Calculate percentage width based on the field's width value
const widthPercent = (field.width / 12) * 100;
return (
<div
key={field.name}
className="flex-shrink-0"
style={{ width: `${widthPercent}%` }}
>
{field.type === 'select' ? (
<select
value={(field.field.includes('.')
? item.param?.[field.field.split('.')[1]]
: item[field.field]) || ''}
onChange={(e) => onChange(
item.id,
field.field,
e.target.value
)}
className="w-full h-9 px-3 py-1 rounded-md bg-neutral-900 border border-neutral-700 text-white text-sm"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
) : field.type === 'checkbox' ? (
<div className="flex items-center h-9 w-full justify-center">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={item.param?.required || false}
onChange={(e) => onChange(
item.id,
field.field,
(e.target as HTMLInputElement).checked
)}
className="accent-emerald-400 rounded"
/>
<span className="text-xs text-neutral-300">Required</span>
</label>
</div>
) : (
<Input
value={(field.field.includes('.')
? item.param?.[field.field.split('.')[1]]
: item[field.field]) || ''}
onChange={(e) => onChange(
item.id,
field.field,
e.target.value
)}
className="h-9 w-full bg-neutral-900 border-neutral-700 text-white placeholder:text-neutral-500 text-sm"
placeholder={field.placeholder}
/>
)}
</div>
);
})}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(item.id)}
className="h-9 w-9 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="py-6 flex flex-col items-center justify-center text-center">
{icon}
<p className="mt-2 text-neutral-400 text-sm">{emptyText}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={onAdd}
className="w-full border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 bg-neutral-800/30"
>
<Plus className="h-4 w-4 mr-1.5" /> {addText}
</Button>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[850px] max-h-[90vh] overflow-hidden flex flex-col bg-neutral-900 border-neutral-700 p-0">
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header Area */}
<div className="flex items-start justify-between p-6 border-b border-neutral-800">
<div>
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<Wand className="h-5 w-5 text-emerald-400" />
{initialTool ? "Edit Custom Tool" : "Create Custom Tool"}
</h2>
<p className="text-neutral-400 text-sm mt-1">
Configure an HTTP tool for your agent to interact with external APIs
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-neutral-800 text-emerald-400 border-emerald-500/30 uppercase text-xs font-semibold px-2 py-0.5">
{tool.method || "GET"}
</Badge>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Side - Navigation */}
<div className="w-[200px] bg-neutral-800/50 border-r border-neutral-800 flex-shrink-0">
<div className="py-4">
<nav className="space-y-1 px-2">
{[
{ id: 'basics', label: 'Basic Info', icon: <Info className="h-4 w-4" /> },
{ id: 'endpoint', label: 'Endpoint', icon: <Globe className="h-4 w-4" /> },
{ id: 'headers', label: 'Headers', icon: <Server className="h-4 w-4" /> },
{ id: 'body', label: 'Body Params', icon: <FileJson className="h-4 w-4" /> },
{ id: 'path', label: 'Path Params', icon: <Code className="h-4 w-4" /> },
{ id: 'query', label: 'Query Params', icon: <LayoutList className="h-4 w-4" /> },
{ id: 'defaults', label: 'Default Values', icon: <Database className="h-4 w-4" /> },
{ id: 'error', label: 'Error Handling', icon: <Settings className="h-4 w-4" /> },
].map(item => (
<button
key={item.id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors",
activeTab === item.id
? "bg-emerald-500/10 text-emerald-400"
: "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
)}
onClick={() => setActiveTab(item.id)}
>
{item.icon}
<span>{item.label}</span>
{(
(item.id === 'headers' && headersList.length > 0) ||
(item.id === 'body' && bodyParams.length > 0) ||
(item.id === 'path' && pathParams.length > 0) ||
(item.id === 'query' && queryParams.length > 0) ||
(item.id === 'defaults' && valuesList.length > 0)
) && (
<span className="ml-auto bg-emerald-500/20 text-emerald-400 text-xs rounded-full px-1.5 py-0.5 min-w-[18px]">
{item.id === 'headers' && headersList.length}
{item.id === 'body' && bodyParams.length}
{item.id === 'path' && pathParams.length}
{item.id === 'query' && queryParams.length}
{item.id === 'defaults' && valuesList.length}
</span>
)}
</button>
))}
</nav>
</div>
</div>
{/* Right Side - Content */}
<div className="flex-1 overflow-auto">
{activeTab === 'basics' && (
<div className="p-6">
<div className="space-y-6">
<ParamField
label="Tool Name"
tooltip="A unique identifier for this tool. Will be used by the agent to reference this tool."
>
<Input
value={tool.name || ""}
onChange={(e) => setTool({ ...tool, name: e.target.value })}
onBlur={(e) => {
const sanitizedName = sanitizeAgentName(e.target.value);
if (sanitizedName !== e.target.value) {
setTool({ ...tool, name: sanitizedName });
}
}}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. weatherApi, searchTool"
/>
</ParamField>
<ParamField
label="Description"
tooltip="A clear description of what this tool does. The agent will use this to determine when to use the tool."
>
<Input
value={tool.description || ""}
onChange={(e) => setTool({ ...tool, description: e.target.value })}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="Provides weather information for a given location"
/>
</ParamField>
</div>
</div>
)}
{activeTab === 'endpoint' && (
<div className="p-6">
<div className="space-y-6">
<ParamField
label="HTTP Method"
tooltip="The HTTP method to use for the request."
>
<div className="flex gap-1">
{['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map(method => (
<Button
key={method}
type="button"
onClick={() => setTool({ ...tool, method })}
className={cn(
"px-4 py-2 rounded font-medium text-sm flex-1",
tool.method === method
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-800 text-neutral-400 border border-neutral-700 hover:bg-neutral-700 hover:text-neutral-200"
)}
>
{method}
</Button>
))}
</div>
</ParamField>
<ParamField
label="Endpoint URL"
tooltip="The complete URL to call. Can include path parameters with the format {paramName}."
>
<div className="space-y-1">
<Input
value={tool.endpoint || ""}
onChange={(e) => setTool({ ...tool, endpoint: e.target.value })}
className="bg-neutral-800 border-neutral-700 text-white font-mono"
placeholder="https://api.example.com/v1/resource/{id}"
/>
{tool.endpoint && tool.endpoint.includes('{') && (
<p className="text-xs text-amber-400">
<Info className="h-3 w-3 inline-block mr-1" />
URL contains path variables. Don't forget to define them in the Path Parameters section.
</p>
)}
</div>
</ParamField>
<div className="p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md mt-6">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> How to use variables in your endpoint
</h3>
<p className="text-neutral-300 text-sm mb-3">
You can use dynamic variables in your endpoint using the <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">{"{VARIABLE_NAME}"}</code> syntax.
</p>
<div className="grid grid-cols-1 gap-4 text-xs">
<div>
<h4 className="font-medium text-white mb-1">Example:</h4>
<code className="block bg-neutral-900 text-neutral-200 p-2 rounded">
https://api.example.com/users/<span className="text-emerald-400">{"{userId}"}</span>/profile
</code>
<p className="mt-1 text-neutral-400">Define <code className="bg-neutral-700 px-1 py-0.5 rounded text-emerald-300">userId</code> as a Path Parameter</p>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'headers' && (
<div className="p-6">
<ParamField
label="HTTP Headers"
tooltip="Headers to send with each request. Common examples include Authorization, Content-Type, Accept, etc."
>
<FieldList
items={headersList}
onAdd={handleAddHeader}
onRemove={handleRemoveHeader}
onChange={(id, field, value) => {
handleHeaderChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Header Name", field: "key", placeholder: "e.g. Authorization", width: 6 },
{ name: "Header Value", field: "value", placeholder: "e.g. Bearer token123", width: 6 }
]}
addText="Add Header"
emptyText="No headers configured. Add headers to customize your HTTP requests."
icon={<Server className="h-10 w-10 text-neutral-700" />}
/>
</ParamField>
</div>
)}
{activeTab === 'body' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Body Parameters
</h3>
<p className="text-neutral-300 text-sm">
Parameters that will be sent in the request body. Only applicable for POST, PUT, and PATCH methods.
You can use variables like <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">{"{variableName}"}</code> that will be replaced at runtime.
</p>
</div>
<FieldList
items={bodyParams}
onAdd={handleAddBodyParam}
onRemove={handleRemoveBodyParam}
onChange={(id, field, value) => {
if (field === "key") {
handleBodyParamChange(id, "key", value as string);
} else if (field.startsWith("param.")) {
const paramField = field.split('.')[1] as keyof HTTPToolParameter;
handleBodyParamChange(id, paramField,
paramField === "required" ? value as boolean : value as string
);
}
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. userId", width: 4 },
{ name: "Type", field: "param.type", placeholder: "", width: 2, type: "select" },
{ name: "Description", field: "param.description", placeholder: "What this parameter does", width: 4 },
{ name: "Required", field: "param.required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Body Parameter"
emptyText="No body parameters configured. Add parameters for POST/PUT/PATCH requests."
icon={<FileJson className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'path' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Path Parameters
</h3>
<p className="text-neutral-300 text-sm">
Path parameters are placeholders in your URL path that will be replaced with actual values.
For example, in <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">https://api.example.com/users/{"{userId}"}</code>,
<code className="bg-neutral-700 px-1 py-0.5 rounded text-emerald-300">userId</code> is a path parameter.
</p>
</div>
<FieldList
items={pathParams}
onAdd={handleAddPathParam}
onRemove={handleRemovePathParam}
onChange={(id, field, value) => {
handlePathParamChange(
id,
field as "key" | "desc",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. userId", width: 4 },
{ name: "Description", field: "desc", placeholder: "What this parameter represents", width: 6 },
{ name: "Required", field: "required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Path Parameter"
emptyText="No path parameters configured. Add parameters if your endpoint URL contains {placeholders}."
icon={<Code className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'query' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Query Parameters
</h3>
<p className="text-neutral-300 text-sm">
Query parameters are added to the URL after a question mark (?) to filter or customize the request.
For example: <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">https://api.example.com/search?query=term&limit=10</code>
</p>
</div>
<FieldList
items={queryParams}
onAdd={handleAddQueryParam}
onRemove={handleRemoveQueryParam}
onChange={(id, field, value) => {
handleQueryParamChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. search", width: 4 },
{ name: "Description", field: "value", placeholder: "What this parameter does", width: 6 },
{ name: "Required", field: "required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Query Parameter"
emptyText="No query parameters configured. Add parameters to customize your URL query string."
icon={<LayoutList className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'defaults' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Default Values
</h3>
<p className="text-neutral-300 text-sm">
Set default values for parameters you've defined in the Body, Path, or Query sections.
These values will be used when the parameter isn't explicitly provided during a tool call.
</p>
</div>
<FieldList
items={valuesList}
onAdd={handleAddValue}
onRemove={handleRemoveValue}
onChange={(id, field, value) => {
handleValueChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "Name of an existing parameter", width: 5 },
{ name: "Default Value", field: "value", placeholder: "Value to use if not provided", width: 7 }
]}
addText="Add Default Value"
emptyText="No default values configured. Add default values for your previously defined parameters."
icon={<Database className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'error' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Error Handling
</h3>
<p className="text-neutral-300 text-sm">
Configure how the tool should handle errors and timeouts when making HTTP requests.
Proper error handling ensures reliable operation of your agent.
</p>
</div>
<div className="space-y-6">
<ParamField
label="Timeout (seconds)"
tooltip="Maximum time to wait for a response before considering the request failed."
>
<Input
type="number"
min={1}
value={timeout}
onChange={(e) => setTimeout(Number(e.target.value))}
className="w-32 bg-neutral-800 border-neutral-700 text-white"
/>
</ParamField>
<ParamField
label="Fallback Error Code"
tooltip="Error code to return if the request fails."
>
<Input
value={fallbackError}
onChange={(e) => setFallbackError(e.target.value)}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. API_ERROR"
/>
</ParamField>
<ParamField
label="Fallback Error Message"
tooltip="Human-readable message to return if the request fails."
>
<Input
value={fallbackMessage}
onChange={(e) => setFallbackMessage(e.target.value)}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. Failed to retrieve data from the API."
/>
</ParamField>
</div>
</div>
)}
</div>
</div>
{/* Footer Area */}
<div className="border-t border-neutral-800 p-4 flex items-center justify-between">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-500 text-neutral-950 hover:bg-emerald-400"
disabled={!tool.name || !tool.endpoint}
>
{initialTool ? "Save Changes" : "Create Tool"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,158 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/FolderDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useState, useEffect } from "react";
interface Folder {
id: string;
name: string;
description: string;
}
interface FolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (folder: { name: string; description: string }) => Promise<void>;
editingFolder: Folder | null;
isLoading?: boolean;
}
export function FolderDialog({
open,
onOpenChange,
onSave,
editingFolder,
isLoading = false,
}: FolderDialogProps) {
const [folder, setFolder] = useState<{
name: string;
description: string;
}>({
name: "",
description: "",
});
useEffect(() => {
if (editingFolder) {
setFolder({
name: editingFolder.name,
description: editingFolder.description,
});
} else {
setFolder({
name: "",
description: "",
});
}
}, [editingFolder, open]);
const handleSave = async () => {
await onSave(folder);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle>
{editingFolder ? "Edit Folder" : "New Folder"}
</DialogTitle>
<DialogDescription className="text-neutral-400">
{editingFolder
? "Update the existing folder information"
: "Fill in the information to create a new folder"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="folder-name" className="text-neutral-300">
Folder Name
</Label>
<Input
id="folder-name"
value={folder.name}
onChange={(e) =>
setFolder({ ...folder, name: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="folder-description" className="text-neutral-300">
Description (optional)
</Label>
<Textarea
id="folder-description"
value={folder.description}
onChange={(e) =>
setFolder({ ...folder, description: e.target.value })
}
className="bg-[#222] border-[#444] text-white resize-none h-20"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!folder.name || isLoading}
>
{isLoading ? (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-1"></div>
) : null}
{editingFolder ? "Save Changes" : "Create Folder"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,239 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/ImportAgentDialog.tsx
Developed by: Davidson Gomes
Creation date: May 15, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Upload, FileJson } from "lucide-react";
import { importAgentFromJson } from "@/services/agentService";
import { useToast } from "@/hooks/use-toast";
interface ImportAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
clientId: string;
folderId?: string | null;
}
export function ImportAgentDialog({
open,
onOpenChange,
onSuccess,
clientId,
folderId,
}: ImportAgentDialogProps) {
const { toast } = useToast();
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile && droppedFile.type === "application/json") {
setFile(droppedFile);
} else {
toast({
title: "Invalid file",
description: "Please upload a JSON file",
variant: "destructive",
});
}
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async () => {
if (!file || !clientId) return;
try {
setIsLoading(true);
await importAgentFromJson(file, clientId, folderId);
toast({
title: "Import successful",
description: folderId
? "Agent was imported successfully and added to the current folder"
: "Agent was imported successfully",
});
// Call the success callback to refresh the agent list
onSuccess();
// Close the dialog
onOpenChange(false);
// Reset state
setFile(null);
} catch (error) {
console.error("Error importing agent:", error);
toast({
title: "Import failed",
description: "There was an error importing the agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const resetState = () => {
if (!isLoading) {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen) {
resetState();
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle className="text-white">Import Agent</DialogTitle>
<DialogDescription className="text-neutral-400">
Upload a JSON file to import an agent
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4 py-4">
<div
className={`h-40 border-2 border-dashed rounded-md flex flex-col items-center justify-center p-4 transition-colors cursor-pointer ${
dragActive
? "border-emerald-400 bg-emerald-900/20"
: file
? "border-emerald-600 bg-emerald-900/10"
: "border-[#444] hover:border-emerald-600/50"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleSelectFile}
>
{file ? (
<div className="flex flex-col items-center space-y-2">
<FileJson className="h-10 w-10 text-emerald-400" />
<span className="text-emerald-400 font-medium">{file.name}</span>
<span className="text-neutral-400 text-xs">
{Math.round(file.size / 1024)} KB
</span>
</div>
) : (
<div className="flex flex-col items-center space-y-2">
<Upload className="h-10 w-10 text-neutral-500" />
<p className="text-neutral-400 text-center">
Drag & drop your JSON file here or click to browse
</p>
<span className="text-neutral-500 text-xs">
Only JSON files are accepted
</span>
</div>
)}
</div>
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleImport}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!file || isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Importing...
</>
) : (
"Import Agent"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,278 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/MCPDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MCPServer } from "@/types/mcpServer";
import { Server } from "lucide-react";
import { useState, useEffect } from "react";
interface MCPDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (mcpConfig: {
id: string;
envs: Record<string, string>;
tools: string[];
}) => void;
availableMCPs: MCPServer[];
selectedMCP?: MCPServer | null;
initialEnvs?: Record<string, string>;
initialTools?: string[];
clientId: string;
}
export function MCPDialog({
open,
onOpenChange,
onSave,
availableMCPs,
selectedMCP: initialSelectedMCP = null,
initialEnvs = {},
initialTools = [],
clientId,
}: MCPDialogProps) {
const [selectedMCP, setSelectedMCP] = useState<MCPServer | null>(null);
const [mcpEnvs, setMcpEnvs] = useState<Record<string, string>>({});
const [selectedMCPTools, setSelectedMCPTools] = useState<string[]>([]);
useEffect(() => {
if (open) {
if (initialSelectedMCP) {
setSelectedMCP(initialSelectedMCP);
setMcpEnvs(initialEnvs);
setSelectedMCPTools(initialTools);
} else {
setSelectedMCP(null);
setMcpEnvs({});
setSelectedMCPTools([]);
}
}
}, [open, initialSelectedMCP, initialEnvs, initialTools]);
const handleSelectMCP = (value: string) => {
const mcp = availableMCPs.find((m) => m.id === value);
if (mcp) {
setSelectedMCP(mcp);
const initialEnvs: Record<string, string> = {};
Object.keys(mcp.environments || {}).forEach((key) => {
initialEnvs[key] = "";
});
setMcpEnvs(initialEnvs);
setSelectedMCPTools([]);
}
};
const toggleMCPTool = (tool: string) => {
if (selectedMCPTools.includes(tool)) {
setSelectedMCPTools(selectedMCPTools.filter((t) => t !== tool));
} else {
setSelectedMCPTools([...selectedMCPTools, tool]);
}
};
const handleSave = () => {
if (!selectedMCP) return;
onSave({
id: selectedMCP.id,
envs: mcpEnvs,
tools: selectedMCPTools,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
Configure MCP Server
</DialogTitle>
<DialogDescription className="text-neutral-400">
Select a MCP server and configure its tools.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mcp-select" className="text-neutral-300">
MCP Server
</Label>
<Select
value={selectedMCP?.id}
onValueChange={handleSelectMCP}
>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select a MCP server" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{availableMCPs.map((mcp) => (
<SelectItem
key={mcp.id}
value={mcp.id}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />
{mcp.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedMCP && (
<>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
<p className="font-medium text-white">{selectedMCP.name}</p>
<p className="text-sm text-neutral-400">
{selectedMCP.description?.substring(0, 100)}...
</p>
<div className="mt-2 text-xs text-neutral-400">
<p>
<strong>Type:</strong> {selectedMCP.type}
</p>
<p>
<strong>Configuration:</strong>{" "}
{selectedMCP.config_type === "sse" ? "SSE" : "Studio"}
</p>
</div>
</div>
{selectedMCP.environments &&
Object.keys(selectedMCP.environments).length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">
Environment Variables
</h3>
{Object.entries(selectedMCP.environments || {}).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-3 items-center gap-4"
>
<Label
htmlFor={`env-${key}`}
className="text-right text-neutral-300"
>
{key}
</Label>
<Input
id={`env-${key}`}
value={mcpEnvs[key] || ""}
onChange={(e) =>
setMcpEnvs({
...mcpEnvs,
[key]: e.target.value,
})
}
className="col-span-2 bg-[#222] border-[#444] text-white"
placeholder={value as string}
/>
</div>
)
)}
</div>
)}
{selectedMCP.tools && selectedMCP.tools.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">
Available Tools
</h3>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
{selectedMCP.tools.map((tool: any) => (
<div
key={tool.id}
className="flex items-center space-x-2 py-1"
>
<Checkbox
id={`tool-${tool.id}`}
checked={selectedMCPTools.includes(tool.id)}
onCheckedChange={() => toggleMCPTool(tool.id)}
className="data-[state=checked]:bg-emerald-400 data-[state=checked]:border-emerald-400"
/>
<Label
htmlFor={`tool-${tool.id}`}
className="text-sm text-neutral-300"
>
{tool.name}
</Label>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!selectedMCP}
>
Add MCP
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,136 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/MoveAgentDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Agent } from "@/types/agent";
import { Folder, Home } from "lucide-react";
interface Folder {
id: string;
name: string;
description: string;
}
interface MoveAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent | null;
folders: Folder[];
onMove: (folderId: string | null) => Promise<void>;
isLoading?: boolean;
}
export function MoveAgentDialog({
open,
onOpenChange,
agent,
folders,
onMove,
isLoading = false,
}: MoveAgentDialogProps) {
const handleMove = async (folderId: string | null) => {
await onMove(folderId);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle>Move Agent</DialogTitle>
<DialogDescription className="text-neutral-400">
Choose a folder to move the agent "{agent?.name}"
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<button
className="w-full text-left px-4 py-3 rounded-md flex items-center bg-[#222] border border-[#444] hover:bg-[#333] hover:border-emerald-400/50 transition-colors"
onClick={() => handleMove(null)}
disabled={isLoading}
>
<Home className="h-5 w-5 mr-3 text-neutral-400" />
<div>
<div className="font-medium">Remove from folder</div>
<p className="text-sm text-neutral-400">
The agent will be visible in "All agents"
</p>
</div>
</button>
{folders.map((folder) => (
<button
key={folder.id}
className="w-full text-left px-4 py-3 rounded-md flex items-center bg-[#222] border border-[#444] hover:bg-[#333] hover:border-emerald-400/50 transition-colors"
onClick={() => handleMove(folder.id)}
disabled={isLoading}
>
<Folder className="h-5 w-5 mr-3 text-emerald-400" />
<div>
<div className="font-medium">{folder.name}</div>
{folder.description && (
<p className="text-sm text-neutral-400 truncate">
{folder.description}
</p>
)}
</div>
</button>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
{isLoading && (
<div className="flex items-center">
<div className="animate-spin h-4 w-4 border-2 border-emerald-400 border-t-transparent rounded-full mr-2"></div>
<span className="text-neutral-400">Moving...</span>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,172 @@
/*
@author: Davidson Gomes
@file: /app/agents/dialogs/ShareAgentDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Agent } from "@/types/agent";
import { Input } from "@/components/ui/input";
import { Copy, Share2, ExternalLink } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface ShareAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent;
apiKey?: string;
}
export function ShareAgentDialog({
open,
onOpenChange,
agent,
apiKey,
}: ShareAgentDialogProps) {
const [copied, setCopied] = useState(false);
const [shareLink, setShareLink] = useState("");
const { toast } = useToast();
useEffect(() => {
if (open && agent && apiKey) {
const baseUrl = window.location.origin;
setShareLink(`${baseUrl}/shared-chat?agent=${agent.id}&key=${apiKey}`);
}
}, [open, agent, apiKey]);
const handleCopyLink = () => {
if (shareLink) {
navigator.clipboard.writeText(shareLink);
setCopied(true);
toast({
title: "Link copied!",
description: "The share link has been copied to the clipboard.",
});
setTimeout(() => setCopied(false), 2000);
}
};
const handleCopyApiKey = () => {
if (apiKey) {
navigator.clipboard.writeText(apiKey);
setCopied(true);
toast({
title: "API Key copied!",
description: "The API Key has been copied to the clipboard.",
});
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5 text-emerald-400" />
Share Agent
</DialogTitle>
<DialogDescription className="text-neutral-400">
Share this agent with others without the need to login.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-white">Share Link</h3>
<div className="flex gap-2">
<Input
readOnly
value={shareLink}
className="bg-[#222] border-[#444] text-white"
/>
<Button
variant="outline"
className="shrink-0 bg-[#222] border-[#444] hover:bg-[#333] text-emerald-400 hover:text-emerald-300"
onClick={handleCopyLink}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-neutral-400">
Any person with this link can access the agent using the included API key.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-white">API Key</h3>
<div className="flex gap-2">
<Input
readOnly
value={apiKey}
type="password"
className="bg-[#222] border-[#444] text-white"
/>
<Button
variant="outline"
className="shrink-0 bg-[#222] border-[#444] hover:bg-[#333] text-emerald-400 hover:text-emerald-300"
onClick={handleCopyApiKey}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-neutral-400">
The API key allows access to the agent. Do not share with untrusted people.
</p>
</div>
</div>
<DialogFooter className="border-t border-[#333] pt-4">
<Button
variant="outline"
onClick={() => window.open(shareLink, "_blank")}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white flex gap-2"
>
<ExternalLink className="h-4 w-4" />
Open Link
</Button>
<Button
onClick={() => onOpenChange(false)}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,331 @@
/*
@author: Davidson Gomes
@file: /app/agents/forms/AgentForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Agent } from "@/types/agent";
import { ApiKey } from "@/services/agentService";
import { MCPServer } from "@/types/mcpServer";
import { useState, useEffect } from "react";
import { BasicInfoTab } from "./BasicInfoTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { SubAgentsTab } from "./SubAgentsTab";
import { MCPDialog } from "../dialogs/MCPDialog";
import { CustomMCPDialog } from "../dialogs/CustomMCPDialog";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface AgentFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialValues: Partial<Agent>;
apiKeys: ApiKey[];
availableModels: ModelOption[];
availableMCPs: MCPServer[];
agents: Agent[];
onOpenApiKeysDialog: () => void;
onOpenMCPDialog: (mcp?: any) => void;
onOpenCustomMCPDialog: (customMCP?: any) => void;
onSave: (values: Partial<Agent>) => Promise<void>;
isLoading?: boolean;
getAgentNameById: (id: string) => string;
clientId: string;
}
export function AgentForm({
open,
onOpenChange,
initialValues,
apiKeys,
availableModels,
availableMCPs,
agents,
onOpenApiKeysDialog,
onOpenMCPDialog: externalOnOpenMCPDialog,
onOpenCustomMCPDialog: externalOnOpenCustomMCPDialog,
onSave,
isLoading = false,
getAgentNameById,
clientId,
}: AgentFormProps) {
const [values, setValues] = useState<Partial<Agent>>(initialValues);
const [activeTab, setActiveTab] = useState("basic");
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [selectedMCP, setSelectedMCP] = useState<any>(null);
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
const [selectedCustomMCP, setSelectedCustomMCP] = useState<any>(null);
useEffect(() => {
if (open) {
setValues(initialValues);
setActiveTab("basic");
}
}, [open, initialValues]);
const handleOpenMCPDialog = (mcpConfig: any = null) => {
setSelectedMCP(mcpConfig);
setMcpDialogOpen(true);
};
const handleOpenCustomMCPDialog = (customMCP: any = null) => {
setSelectedCustomMCP(customMCP);
setCustomMcpDialogOpen(true);
};
const handleConfigureMCP = (mcpConfig: any) => {
handleOpenMCPDialog(mcpConfig);
};
const handleRemoveMCP = (mcpId: string) => {
setValues({
...values,
config: {
...values.config,
mcp_servers:
values.config?.mcp_servers?.filter((mcp) => mcp.id !== mcpId) || [],
},
});
};
const handleConfigureCustomMCP = (customMCP: any) => {
handleOpenCustomMCPDialog(customMCP);
};
const handleRemoveCustomMCP = (url: string) => {
setValues({
...values,
config: {
...values.config,
custom_mcp_servers:
values.config?.custom_mcp_servers?.filter(
(customMCP) => customMCP.url !== url
) || [],
},
});
};
const handleSaveMCP = (mcpConfig: any) => {
const updatedMcpServers = [...(values.config?.mcp_servers || [])];
const existingIndex = updatedMcpServers.findIndex(
(mcp) => mcp.id === mcpConfig.id
);
if (existingIndex >= 0) {
updatedMcpServers[existingIndex] = mcpConfig;
} else {
updatedMcpServers.push(mcpConfig);
}
setValues({
...values,
config: {
...(values.config || {}),
mcp_servers: updatedMcpServers,
},
});
};
const handleSaveCustomMCP = (customMCP: any) => {
const updatedCustomMCPs = [...(values.config?.custom_mcp_servers || [])];
const existingIndex = updatedCustomMCPs.findIndex(
(mcp) => mcp.url === customMCP.url
);
if (existingIndex >= 0) {
updatedCustomMCPs[existingIndex] = customMCP;
} else {
updatedCustomMCPs.push(customMCP);
}
setValues({
...values,
config: {
...(values.config || {}),
custom_mcp_servers: updatedCustomMCPs,
},
});
};
const handleSave = async () => {
const finalValues = {
...values,
client_id: clientId,
name: values.name,
};
await onSave(finalValues);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
{initialValues.id ? "Edit Agent" : "New Agent"}
</DialogTitle>
<DialogDescription className="text-neutral-400">
{initialValues.id
? "Edit the existing agent information"
: "Fill in the information to create a new agent"}
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 overflow-hidden flex flex-col"
>
<TabsList className="grid grid-cols-3 bg-[#222]">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Basic Information
</TabsTrigger>
<TabsTrigger
value="config"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Configuration
</TabsTrigger>
<TabsTrigger
value="subagents"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Sub-Agents
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 overflow-auto">
<TabsContent value="basic" className="p-4 space-y-4">
<BasicInfoTab
values={values}
onChange={setValues}
apiKeys={apiKeys}
availableModels={availableModels}
onOpenApiKeysDialog={onOpenApiKeysDialog}
clientId={clientId}
/>
</TabsContent>
<TabsContent value="config" className="p-4 space-y-4">
<ConfigurationTab
values={values}
onChange={setValues}
agents={agents}
availableMCPs={availableMCPs}
apiKeys={apiKeys}
availableModels={availableModels}
getAgentNameById={getAgentNameById}
onOpenApiKeysDialog={onOpenApiKeysDialog}
onConfigureMCP={handleConfigureMCP}
onRemoveMCP={handleRemoveMCP}
onConfigureCustomMCP={handleConfigureCustomMCP}
onRemoveCustomMCP={handleRemoveCustomMCP}
onOpenMCPDialog={handleOpenMCPDialog}
onOpenCustomMCPDialog={handleOpenCustomMCPDialog}
clientId={clientId}
/>
</TabsContent>
<TabsContent value="subagents" className="p-4 space-y-4">
<SubAgentsTab
values={values}
onChange={setValues}
getAgentNameById={getAgentNameById}
editingAgentId={initialValues.id}
clientId={clientId}
/>
</TabsContent>
</ScrollArea>
</Tabs>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!values.name || isLoading}
>
{isLoading && (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-2"></div>
)}
{initialValues.id ? "Save Changes" : "Add Agent"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* MCP Dialog */}
<MCPDialog
open={mcpDialogOpen}
onOpenChange={setMcpDialogOpen}
onSave={handleSaveMCP}
availableMCPs={availableMCPs}
selectedMCP={
availableMCPs.find((m) => selectedMCP?.id === m.id) || null
}
initialEnvs={selectedMCP?.envs || {}}
initialTools={selectedMCP?.tools || []}
clientId={clientId}
/>
{/* Custom MCP Dialog */}
<CustomMCPDialog
open={customMcpDialogOpen}
onOpenChange={setCustomMcpDialogOpen}
onSave={handleSaveCustomMCP}
initialCustomMCP={selectedCustomMCP}
clientId={clientId}
/>
</>
);
}

View File

@ -0,0 +1,243 @@
/*
@author: Davidson Gomes
@file: /app/agents/forms/BasicInfoTab.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { AgentTypeSelector } from "@/app/agents/AgentTypeSelector";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Agent, AgentType } from "@/types/agent";
import { ApiKey } from "@/services/agentService";
import { A2AAgentConfig } from "../config/A2AAgentConfig";
import { LLMAgentConfig } from "../config/LLMAgentConfig";
import { sanitizeAgentName } from "@/lib/utils";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface BasicInfoTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
apiKeys: ApiKey[];
availableModels: ModelOption[];
onOpenApiKeysDialog: () => void;
clientId: string;
}
export function BasicInfoTab({
values,
onChange,
apiKeys,
availableModels,
onOpenApiKeysDialog,
}: BasicInfoTabProps) {
const handleNameBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const sanitizedName = sanitizeAgentName(e.target.value);
if (sanitizedName !== e.target.value) {
onChange({ ...values, name: sanitizedName });
}
};
const handleTypeChange = (type: AgentType) => {
let newValues: Partial<Agent> = { ...values, type };
if (type === "llm") {
newValues = {
...newValues,
model: "openai/gpt-4.1-nano",
instruction: "",
role: "",
goal: "",
agent_card_url: undefined,
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
},
};
} else if (type === "a2a") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: "",
api_key_id: undefined,
config: undefined,
};
} else if (type === "loop") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
custom_mcp_servers: [],
},
};
} else if (type === "workflow") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
workflow: {
nodes: [],
edges: [],
},
},
};
} else if (type === "task") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
tasks: [],
sub_agents: [],
},
};
} else {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
custom_mcp_servers: [],
},
};
}
onChange(newValues);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right text-neutral-300">
Agent Type
</Label>
<div className="col-span-3">
<AgentTypeSelector
value={values.type || "llm"}
onValueChange={handleTypeChange}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right text-neutral-300">
Name
</Label>
<Input
id="name"
value={values.name || ""}
onChange={(e) => onChange({ ...values, name: e.target.value })}
onBlur={handleNameBlur}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
{values.type !== "a2a" && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right text-neutral-300">
Description
</Label>
<Input
id="description"
value={values.description || ""}
onChange={(e) =>
onChange({ ...values, description: e.target.value })
}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
)}
{values.type === "llm" && (
<LLMAgentConfig
apiKeys={apiKeys}
availableModels={availableModels}
values={values}
onChange={onChange}
onOpenApiKeysDialog={onOpenApiKeysDialog}
/>
)}
{values.type === "loop" && values.config?.max_iterations && (
<div className="space-y-1 text-xs text-neutral-400">
<div>
<strong>Max. Iterations:</strong> {values.config.max_iterations}
</div>
</div>
)}
{values.type === "workflow" && (
<div className="space-y-1 text-xs text-neutral-400">
<div>
<strong>Type:</strong> Visual Flow
</div>
{values.config?.workflow && (
<div>
<strong>Elements:</strong>{" "}
{values.config.workflow.nodes?.length || 0} nodes,{" "}
{values.config.workflow.edges?.length || 0} connections
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,722 @@
/*
@author: Davidson Gomes
@file: /app/agents/forms/ConfigurationTab.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Agent } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import {
Check,
Copy,
Eye,
EyeOff,
Plus,
Server,
Settings,
X,
} from "lucide-react";
import { ParallelAgentConfig } from "../config/ParallelAgentConfig";
import { SequentialAgentConfig } from "../config/SequentialAgentConfig";
import { ApiKey } from "@/services/agentService";
import { LoopAgentConfig } from "../config/LoopAgentConfig copy";
import { A2AAgentConfig } from "../config/A2AAgentConfig";
import { TaskAgentConfig } from "../config/TaskAgentConfig";
import { useState } from "react";
import { MCPDialog } from "../dialogs/MCPDialog";
import { CustomMCPDialog } from "../dialogs/CustomMCPDialog";
import { AgentToolDialog } from "../dialogs/AgentToolDialog";
import { CustomToolDialog } from "../dialogs/CustomToolDialog";
import { useToast } from "@/hooks/use-toast";
interface ConfigurationTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
agents: Agent[];
availableMCPs: MCPServer[];
apiKeys: ApiKey[];
availableModels: any[];
getAgentNameById: (id: string) => string;
onOpenApiKeysDialog: () => void;
onConfigureMCP: (mcpConfig: any) => void;
onRemoveMCP: (mcpId: string) => void;
onConfigureCustomMCP: (customMCP: any) => void;
onRemoveCustomMCP: (url: string) => void;
onOpenMCPDialog: (mcpConfig?: any) => void;
onOpenCustomMCPDialog: (customMCP?: any) => void;
clientId: string;
}
export function ConfigurationTab({
values,
onChange,
agents,
availableMCPs,
apiKeys,
availableModels,
getAgentNameById,
onOpenApiKeysDialog,
onConfigureMCP,
onRemoveMCP,
onConfigureCustomMCP,
onRemoveCustomMCP,
onOpenMCPDialog,
onOpenCustomMCPDialog,
clientId,
}: ConfigurationTabProps) {
const [agentToolDialogOpen, setAgentToolDialogOpen] = useState(false);
const [customToolDialogOpen, setCustomToolDialogOpen] = useState(false);
const [editingCustomTool, setEditingCustomTool] = useState<any>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [copied, setCopied] = useState(false);
const [copiedCardUrl, setCopiedCardUrl] = useState(false);
const { toast } = useToast();
const handleAddAgentTool = (tool: { id: string }) => {
const updatedAgentTools = [...(values.config?.agent_tools || [])];
if (!updatedAgentTools.includes(tool.id)) {
updatedAgentTools.push(tool.id);
onChange({
...values,
config: {
...(values.config || {}),
agent_tools: updatedAgentTools,
},
});
}
};
const handleRemoveAgentTool = (id: string) => {
onChange({
...values,
config: {
...(values.config || {}),
agent_tools: (values.config?.agent_tools || []).filter(
(toolId) => toolId !== id
),
},
});
};
// Custom Tools handlers
const handleAddCustomTool = (tool: any) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
updatedTools.push(tool);
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
};
const handleEditCustomTool = (tool: any, idx: number) => {
setEditingCustomTool({ ...tool, idx });
setCustomToolDialogOpen(true);
};
const handleSaveEditCustomTool = (tool: any) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
if (editingCustomTool && typeof editingCustomTool.idx === "number") {
updatedTools[editingCustomTool.idx] = tool;
}
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
setEditingCustomTool(null);
};
const handleRemoveCustomTool = (idx: number) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
updatedTools.splice(idx, 1);
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
};
const apiKeyField = (
<div className="space-y-2 mb-4">
<h3 className="text-lg font-medium text-white">Credentials</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222] flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
className="text-sm text-neutral-400 mb-1"
htmlFor="agent-card-url"
>
Agent URL. This URL can be used to access the agent card externally.
</label>
<div className="relative flex items-center">
<input
id="agent-card-url"
type="text"
className="w-full bg-[#2a2a2a] border border-[#444] rounded-md px-3 py-2 text-white pr-12 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
value={
values?.agent_card_url?.replace(
"/.well-known/agent.json",
""
) || ""
}
disabled
autoComplete="off"
/>
<button
type="button"
className="absolute right-2 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={async () => {
if (values?.agent_card_url) {
await navigator.clipboard.writeText(
values.agent_card_url.replace("/.well-known/agent.json", "")
);
setCopiedCardUrl(true);
setTimeout(() => setCopiedCardUrl(false), 1200);
toast({
title: "Copied!",
description:
"The agent URL was copied to the clipboard.",
});
}
}}
tabIndex={-1}
>
{copiedCardUrl ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-neutral-400 mb-1" htmlFor="agent-api_key">
Configure the API Key for this agent. This key will be used for
authentication with external services.
</label>
<div className="relative flex items-center">
<input
id="agent-api_key"
type={showApiKey ? "text" : "password"}
className="w-full bg-[#2a2a2a] border border-[#444] rounded-md px-3 py-2 text-white pr-24 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
value={values.config?.api_key || ""}
onChange={(e) =>
onChange({
...values,
config: {
...(values.config || {}),
api_key: e.target.value,
},
})
}
autoComplete="off"
/>
<button
type="button"
className="absolute right-9 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={() => setShowApiKey((v) => !v)}
tabIndex={-1}
>
{showApiKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
<button
type="button"
className="absolute right-2 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={async () => {
if (values.config?.api_key) {
await navigator.clipboard.writeText(values.config.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast({
title: "Copied!",
description:
"The API key was copied to the clipboard.",
});
}
}}
tabIndex={-1}
>
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
</div>
);
if (values.type === "llm") {
return (
<div className="space-y-4">
{apiKeyField}
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">MCP Servers</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure the MCP servers that this agent can use.
</p>
{values.config?.mcp_servers &&
values.config.mcp_servers.length > 0 ? (
<div className="space-y-2">
{values.config.mcp_servers.map((mcpConfig) => {
const mcpServer = availableMCPs.find(
(mcp) => mcp.id === mcpConfig.id
);
return (
<div
key={mcpConfig.id}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">
{mcpServer?.name || mcpConfig.id}
</p>
<p className="text-sm text-neutral-400">
{mcpServer?.description?.substring(0, 100)}
...
</p>
{mcpConfig.tools && mcpConfig.tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{mcpConfig.tools.map((toolId) => (
<Badge
key={toolId}
variant="outline"
className="text-xs bg-[#333] text-emerald-400 border-emerald-400/30"
>
{toolId}
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onConfigureMCP(mcpConfig)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Settings className="h-4 w-4 mr-1" /> Configure
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveMCP(mcpConfig.id)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => onOpenMCPDialog(null)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add MCP Server
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No MCP servers configured
</p>
<p className="text-sm text-neutral-400">
Add MCP servers for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenMCPDialog(null)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">Custom MCPs</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure custom MCPs with URL and HTTP headers.
</p>
{values.config?.custom_mcp_servers &&
values.config.custom_mcp_servers.length > 0 ? (
<div className="space-y-2">
{values.config.custom_mcp_servers.map((customMCP) => (
<div
key={customMCP.url}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">{customMCP.url}</p>
<p className="text-sm text-neutral-400">
{Object.keys(customMCP.headers || {}).length > 0
? `${
Object.keys(customMCP.headers || {}).length
} headers configured`
: "No headers configured"}
</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onConfigureCustomMCP(customMCP)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Settings className="h-4 w-4 mr-1" /> Configure
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveCustomMCP(customMCP.url)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => onOpenCustomMCPDialog(null)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Custom MCP
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No custom MCPs configured
</p>
<p className="text-sm text-neutral-400">
Add custom MCPs for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenCustomMCPDialog(null)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">Agent Tools</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure other agents as tools for this agent.
</p>
{values.config?.agent_tools &&
values.config.agent_tools.length > 0 ? (
<div className="space-y-2">
{values.config.agent_tools.map((toolId) => {
const agent = agents.find((a) => a.id === toolId);
return (
<div
key={toolId}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">
{agent?.name || toolId}
</p>
<p className="text-sm text-neutral-400">
{agent?.description || "No description"}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAgentTool(toolId)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => setAgentToolDialogOpen(true)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Agent Tool
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No agent tools configured
</p>
<p className="text-sm text-neutral-400">
Add agent tools for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setAgentToolDialogOpen(true)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">
Custom Tools (HTTP Tools)
</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure HTTP tools for this agent.
</p>
{values.config?.custom_tools?.http_tools &&
values.config.custom_tools.http_tools.length > 0 ? (
<div className="space-y-2">
{values.config.custom_tools.http_tools.map((tool, idx) => (
<div
key={tool.name + idx}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">{tool.name}</p>
<p className="text-xs text-neutral-400">
{tool.method} {tool.endpoint}
</p>
<p className="text-xs text-neutral-400">
{tool.description}
</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditCustomTool(tool, idx)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<span className="mr-1">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCustomTool(idx)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomTool(null);
setCustomToolDialogOpen(true);
}}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Custom Tool
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No custom tools configured
</p>
<p className="text-sm text-neutral-400">
Add HTTP tools for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomTool(null);
setCustomToolDialogOpen(true);
}}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<CustomToolDialog
open={customToolDialogOpen}
onOpenChange={(open) => {
setCustomToolDialogOpen(open);
if (!open) setEditingCustomTool(null);
}}
onSave={
editingCustomTool ? handleSaveEditCustomTool : handleAddCustomTool
}
initialTool={editingCustomTool}
/>
{agentToolDialogOpen && (
<AgentToolDialog
open={agentToolDialogOpen}
onOpenChange={setAgentToolDialogOpen}
onSave={handleAddAgentTool}
currentAgentId={values.id}
folderId={values.folder_id}
clientId={clientId}
/>
)}
</div>
);
}
if (values.type === "a2a") {
return (
<div className="space-y-4">
{apiKeyField}
<A2AAgentConfig values={values} onChange={onChange} />
</div>
);
}
if (values.type === "sequential") {
return (
<div className="space-y-4">
{apiKeyField}
<SequentialAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "parallel") {
return (
<div className="space-y-4">
{apiKeyField}
<ParallelAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "loop") {
return (
<div className="space-y-4">
{apiKeyField}
<LoopAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "task") {
return (
<div className="space-y-4">
{apiKeyField}
<TaskAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
singleTask={values.type === "task"}
/>
</div>
);
}
return (
<div className="space-y-4">
{apiKeyField}
<div className="flex items-center justify-center h-40">
<div className="text-center">
<p className="text-neutral-400">
Configure the sub-agents in the "Sub-Agents" tab
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,246 @@
/*
@author: Davidson Gomes
@file: /app/agents/forms/SubAgentsTab.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Agent } from "@/types/agent";
import { listAgents } from "@/services/agentService";
import { Loader2, Search, X } from "lucide-react";
interface SubAgentsTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
getAgentNameById: (id: string) => string;
editingAgentId?: string;
clientId: string;
}
export function SubAgentsTab({
values,
onChange,
getAgentNameById,
editingAgentId,
clientId,
}: SubAgentsTabProps) {
const [availableAgents, setAvailableAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [search, setSearch] = useState("");
// Get folder ID from current agent
const folderId = values.folder_id;
useEffect(() => {
loadAgents();
}, [clientId, folderId, editingAgentId]);
const loadAgents = async () => {
if (!clientId) return;
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
folderId || undefined
);
// Filter out the current agent to avoid self-reference
const filteredAgents = res.data.filter(agent =>
agent.id !== editingAgentId
);
setAvailableAgents(filteredAgents);
} catch (error) {
console.error("Error loading agents:", error);
} finally {
setIsLoading(false);
}
};
const handleAddSubAgent = (agentId: string) => {
if (!values.config?.sub_agents?.includes(agentId)) {
onChange({
...values,
config: {
...values.config,
sub_agents: [...(values.config?.sub_agents || []), agentId],
},
});
}
};
const handleRemoveSubAgent = (agentId: string) => {
onChange({
...values,
config: {
...values.config,
sub_agents:
values.config?.sub_agents?.filter((id) => id !== agentId) || [],
},
});
};
const filteredAgents = availableAgents.filter(agent =>
agent.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">Sub-Agents</h3>
<div className="text-sm text-neutral-400">
{values.config?.sub_agents?.length || 0} sub-agents selected
</div>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Select the agents that will be used as sub-agents.
</p>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-2 mb-4">
<h4 className="text-sm font-medium text-white">
Selected sub-agents:
</h4>
<div className="flex flex-wrap gap-2">
{values.config.sub_agents.map((agentId) => (
<Badge
key={agentId}
variant="secondary"
className="flex items-center gap-1 bg-[#333] text-emerald-400"
>
{getAgentNameById(agentId)}
<button
onClick={() => handleRemoveSubAgent(agentId)}
className="ml-1 h-4 w-4 rounded-full hover:bg-[#444] inline-flex items-center justify-center"
>
×
</button>
</Badge>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-neutral-400 mb-4">
No sub-agents selected
</div>
)}
<div className="mb-4">
<h4 className="text-sm font-medium text-white mb-2">
Available agents:
</h4>
<div className="relative mb-3">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search agents by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-[#1a1a1a] border-[#444] text-white"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-2.5 top-2.5 text-neutral-400 hover:text-white"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-6">
<Loader2 className="h-6 w-6 text-emerald-400 animate-spin" />
<div className="mt-2 text-sm text-neutral-400">Loading agents...</div>
</div>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{filteredAgents.length === 0 ? (
<div className="text-center py-4 text-neutral-400">
{search ? `No agents found matching "${search}"` : "No other agents found in this folder"}
</div>
) : (
filteredAgents.map((agent) => (
<div
key={agent.id}
className="flex items-center justify-between p-2 hover:bg-[#2a2a2a] rounded-md"
>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{agent.name}</span>
<Badge
variant="outline"
className="ml-2 border-[#444] text-emerald-400"
>
{agent.type === "llm"
? "LLM Agent"
: agent.type === "a2a"
? "A2A Agent"
: agent.type === "sequential"
? "Sequential Agent"
: agent.type === "parallel"
? "Parallel Agent"
: agent.type === "loop"
? "Loop Agent"
: agent.type === "workflow"
? "Workflow Agent"
: agent.type === "task"
? "Task Agent"
: agent.type}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleAddSubAgent(agent.id)}
disabled={values.config?.sub_agents?.includes(agent.id)}
className={
values.config?.sub_agents?.includes(agent.id)
? "text-neutral-500 bg-[#222] hover:bg-[#333]"
: "text-emerald-400 hover:bg-[#333] bg-[#222]"
}
>
{values.config?.sub_agents?.includes(agent.id)
? "Added"
: "Add"}
</Button>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,754 @@
/*
@author: Davidson Gomes
@file: /app/agents/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Key, Plus, Folder, Download, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { exportAsJson } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Agent, AgentCreate } from "@/types/agent";
import { Folder as AgentFolder } from "@/services/agentService";
import {
listAgents,
createAgent,
updateAgent,
deleteAgent,
listFolders,
createFolder,
updateFolder,
deleteFolder,
assignAgentToFolder,
ApiKey,
listApiKeys,
createApiKey,
updateApiKey,
deleteApiKey,
shareAgent,
importAgentFromJson,
} from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { AgentSidebar } from "./AgentSidebar";
import { SearchInput } from "./SearchInput";
import { AgentList } from "./AgentList";
import { EmptyState } from "./EmptyState";
import { AgentForm } from "./forms/AgentForm";
import { FolderDialog } from "./dialogs/FolderDialog";
import { MoveAgentDialog } from "./dialogs/MoveAgentDialog";
import { ConfirmationDialog } from "./dialogs/ConfirmationDialog";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog";
import { ShareAgentDialog } from "./dialogs/ShareAgentDialog";
import { MCPServer } from "@/types/mcpServer";
import { availableModels } from "@/types/aiModels";
import { ImportAgentDialog } from "./dialogs/ImportAgentDialog";
export default function AgentsPage() {
const { toast } = useToast();
const router = useRouter();
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
const [agents, setAgents] = useState<Agent[]>([]);
const [filteredAgents, setFilteredAgents] = useState<Agent[]>([]);
const [folders, setFolders] = useState<AgentFolder[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [selectedAgentType, setSelectedAgentType] = useState<string | null>(null);
const [agentTypes, setAgentTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false);
const [isMovingDialogOpen, setIsMovingDialogOpen] = useState(false);
const [isDeleteAgentDialogOpen, setIsDeleteAgentDialogOpen] = useState(false);
const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] =
useState(false);
const [isApiKeysDialogOpen, setIsApiKeysDialogOpen] = useState(false);
const [isMCPDialogOpen, setIsMCPDialogOpen] = useState(false);
const [isCustomMCPDialogOpen, setIsCustomMCPDialogOpen] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
const [editingFolder, setEditingFolder] = useState<AgentFolder | null>(null);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
const [agentToMove, setAgentToMove] = useState<Agent | null>(null);
const [agentToShare, setAgentToShare] = useState<Agent | null>(null);
const [sharedApiKey, setSharedApiKey] = useState<string>("");
const [folderToDelete, setFolderToDelete] = useState<AgentFolder | null>(null);
const [newAgent, setNewAgent] = useState<Partial<Agent>>({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
agent_tools: [],
},
});
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImporting, setIsImporting] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
useEffect(() => {
if (!clientId) return;
loadAgents();
loadFolders();
loadApiKeys();
}, [clientId, selectedFolderId]);
useEffect(() => {
const loadMCPs = async () => {
try {
const res = await listMCPServers();
setAvailableMCPs(res.data);
} catch (error) {
toast({
title: "Error loading MCP servers",
variant: "destructive",
});
}
};
loadMCPs();
}, []);
const loadAgents = async () => {
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
selectedFolderId || undefined
);
setAgents(res.data);
setFilteredAgents(res.data);
// Extract unique agent types
const types = [...new Set(res.data.map(agent => agent.type))].filter(Boolean);
setAgentTypes(types);
} catch (error) {
toast({ title: "Error loading agents", variant: "destructive" });
} finally {
setIsLoading(false);
}
};
const loadFolders = async () => {
setIsLoading(true);
try {
const res = await listFolders(clientId);
setFolders(res.data);
} catch (error) {
toast({ title: "Error loading folders", variant: "destructive" });
} finally {
setIsLoading(false);
}
};
const loadApiKeys = async () => {
try {
const res = await listApiKeys(clientId);
setApiKeys(res.data);
} catch (error) {
toast({ title: "Error loading API keys", variant: "destructive" });
}
};
useEffect(() => {
// Apply both search term and type filters
let filtered = [...agents];
// Apply search term filter
if (searchTerm.trim() !== "") {
const lowercaseSearch = searchTerm.toLowerCase();
filtered = filtered.filter(
(agent) =>
agent.name.toLowerCase().includes(lowercaseSearch) ||
agent.description?.toLowerCase().includes(lowercaseSearch)
);
}
// Apply agent type filter
if (selectedAgentType) {
filtered = filtered.filter(agent => agent.type === selectedAgentType);
}
setFilteredAgents(filtered);
}, [searchTerm, selectedAgentType, agents]);
const handleAddAgent = async (agentData: Partial<Agent>) => {
try {
setIsLoading(true);
if (editingAgent) {
await updateAgent(editingAgent.id, {
...agentData,
client_id: clientId,
});
toast({
title: "Agent updated",
description: `${agentData.name} was updated successfully`,
});
} else {
const createdAgent = await createAgent({
...(agentData as AgentCreate),
client_id: clientId,
});
if (selectedFolderId && createdAgent.data.id) {
await assignAgentToFolder(
createdAgent.data.id,
selectedFolderId,
clientId
);
}
toast({
title: "Agent added",
description: `${agentData.name} was added successfully`,
});
}
loadAgents();
setIsDialogOpen(false);
resetForm();
} catch (error) {
toast({
title: "Error",
description: "Unable to save agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteAgent = async () => {
if (!agentToDelete) return;
try {
setIsLoading(true);
await deleteAgent(agentToDelete.id);
toast({
title: "Agent deleted",
description: "The agent was deleted successfully",
});
loadAgents();
setAgentToDelete(null);
setIsDeleteAgentDialogOpen(false);
} catch {
toast({
title: "Error",
description: "Unable to delete agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleEditAgent = (agent: Agent) => {
setEditingAgent(agent);
setNewAgent({ ...agent });
setIsDialogOpen(true);
};
const handleMoveAgent = async (targetFolderId: string | null) => {
if (!agentToMove) return;
try {
setIsLoading(true);
await assignAgentToFolder(agentToMove.id, targetFolderId, clientId);
toast({
title: "Agent moved",
description: targetFolderId
? `Agent moved to folder successfully`
: "Agent removed from folder successfully",
});
setIsMovingDialogOpen(false);
loadAgents();
} catch (error) {
toast({
title: "Error",
description: "Unable to move agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
setAgentToMove(null);
}
};
const handleAddFolder = async (folderData: {
name: string;
description: string;
}) => {
try {
setIsLoading(true);
if (editingFolder) {
await updateFolder(editingFolder.id, folderData, clientId);
toast({
title: "Folder updated",
description: `${folderData.name} was updated successfully`,
});
} else {
await createFolder({
...folderData,
client_id: clientId,
});
toast({
title: "Folder created",
description: `${folderData.name} was created successfully`,
});
}
loadFolders();
setIsFolderDialogOpen(false);
setEditingFolder(null);
} catch (error) {
toast({
title: "Error",
description: "Unable to save folder",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteFolder = async () => {
if (!folderToDelete) return;
try {
setIsLoading(true);
await deleteFolder(folderToDelete.id, clientId);
toast({
title: "Folder deleted",
description: "The folder was deleted successfully",
});
loadFolders();
if (selectedFolderId === folderToDelete.id) {
setSelectedFolderId(null);
}
setFolderToDelete(null);
setIsDeleteFolderDialogOpen(false);
} catch (error) {
toast({
title: "Error",
description: "Unable to delete folder",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleShareAgent = async (agent: Agent) => {
try {
setIsLoading(true);
setAgentToShare(agent);
const response = await shareAgent(agent.id, clientId);
if (response.data && response.data.api_key) {
setSharedApiKey(response.data.api_key);
setIsShareDialogOpen(true);
toast({
title: "Agent shared",
description: "API key generated successfully",
});
}
} catch (error) {
toast({
title: "Error",
description: "Unable to share agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setNewAgent({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
agent_tools: [],
},
});
setEditingAgent(null);
};
// Function to export all agents as JSON
const handleExportAllAgents = () => {
try {
// Create file name with current date
const date = new Date();
const formattedDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
const filename = `agents-export-${formattedDate}`;
// Use the utility function to export
// Pass agents both as the data and as allAgents parameter to properly resolve references
const result = exportAsJson({ agents: filteredAgents }, filename, true, agents);
if (result) {
toast({
title: "Export complete",
description: `${filteredAgents.length} agent(s) exported to JSON`,
});
} else {
throw new Error("Export failed");
}
} catch (error) {
console.error("Error exporting agents:", error);
toast({
title: "Export failed",
description: "There was an error exporting the agents",
variant: "destructive",
});
}
};
const handleImportAgentJSON = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !clientId) return;
try {
setIsImporting(true);
await importAgentFromJson(file, clientId);
toast({
title: "Import successful",
description: "Agent was imported successfully",
});
// Refresh the agent list
loadAgents();
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) {
console.error("Error importing agent:", error);
toast({
title: "Import failed",
description: "There was an error importing the agent",
variant: "destructive",
});
} finally {
setIsImporting(false);
}
};
return (
<div className="container mx-auto p-6 bg-[#121212] min-h-screen flex relative">
<AgentSidebar
visible={isSidebarVisible}
folders={folders}
selectedFolderId={selectedFolderId}
onSelectFolder={setSelectedFolderId}
onAddFolder={() => {
setEditingFolder(null);
setIsFolderDialogOpen(true);
}}
onEditFolder={(folder) => {
setEditingFolder(folder as AgentFolder);
setIsFolderDialogOpen(true);
}}
onDeleteFolder={(folder) => {
setFolderToDelete(folder as AgentFolder);
setIsDeleteFolderDialogOpen(true);
}}
onClose={() => setIsSidebarVisible(!isSidebarVisible)}
/>
<div
className={`w-full transition-all duration-300 ease-in-out ${
isSidebarVisible ? "pl-64" : "pl-0"
}`}
>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center">
{!isSidebarVisible && (
<button
onClick={() => setIsSidebarVisible(true)}
className="mr-2 bg-[#222] p-2 rounded-md text-emerald-400 hover:bg-[#333] hover:text-emerald-400 shadow-md transition-all"
aria-label="Show folders"
>
<Folder className="h-5 w-5" />
</button>
)}
<h1 className="text-3xl font-bold text-white flex items-center ml-2">
{selectedFolderId
? folders.find((f) => f.id === selectedFolderId)?.name
: "Agents"}
</h1>
</div>
<div className="flex items-center gap-4">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search agents..."
selectedAgentType={selectedAgentType}
onAgentTypeChange={setSelectedAgentType}
agentTypes={agentTypes}
/>
<Button
onClick={() => setIsApiKeysDialogOpen(true)}
className="bg-[#222] text-white hover:bg-[#333] border border-[#444]"
>
<Key className="mr-2 h-4 w-4 text-emerald-400" />
API Keys
</Button>
<Button
onClick={handleExportAllAgents}
className="bg-[#222] text-white hover:bg-[#333] border border-[#444]"
title="Export all agents as JSON"
>
<Download className="mr-2 h-4 w-4 text-purple-400" />
Export All
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
<Plus className="mr-2 h-4 w-4" />
New Agent
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-zinc-900 border-zinc-700">
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => {
resetForm();
setIsDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2 text-emerald-400" />
New Agent
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => setIsImportDialogOpen(true)}
>
<Upload className="h-4 w-4 mr-2 text-indigo-400" />
Import Agent JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-400"></div>
</div>
) : filteredAgents.length > 0 ? (
<AgentList
agents={filteredAgents}
isLoading={isLoading}
searchTerm={searchTerm}
selectedFolderId={selectedFolderId}
availableMCPs={availableMCPs}
getApiKeyNameById={(id) =>
apiKeys.find((k) => k.id === id)?.name || null
}
getAgentNameById={(id) =>
agents.find((a) => a.id === id)?.name || id
}
onEdit={handleEditAgent}
onDelete={(agent) => {
setAgentToDelete(agent);
setIsDeleteAgentDialogOpen(true);
}}
onMove={(agent) => {
setAgentToMove(agent);
setIsMovingDialogOpen(true);
}}
onShare={handleShareAgent}
onClearSearch={() => {
setSearchTerm("");
setSelectedAgentType(null);
}}
onCreateAgent={() => {
resetForm();
setIsDialogOpen(true);
}}
onWorkflow={(agentId) => {
router.push(`/agents/workflows?agentId=${agentId}`);
}}
apiKeys={apiKeys}
folders={folders}
/>
) : (
<EmptyState
type={
searchTerm || selectedAgentType
? "search-no-results"
: selectedFolderId
? "empty-folder"
: "no-agents"
}
searchTerm={searchTerm}
onAction={() => {
searchTerm || selectedAgentType
? (setSearchTerm(""), setSelectedAgentType(null))
: (resetForm(), setIsDialogOpen(true));
}}
actionLabel={searchTerm || selectedAgentType ? "Clear filters" : "Create Agent"}
/>
)}
</div>
<AgentForm
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
initialValues={newAgent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={agents}
onOpenApiKeysDialog={() => setIsApiKeysDialogOpen(true)}
onOpenMCPDialog={() => setIsMCPDialogOpen(true)}
onOpenCustomMCPDialog={() => setIsCustomMCPDialogOpen(true)}
onSave={handleAddAgent}
getAgentNameById={(id) => agents.find((a) => a.id === id)?.name || id}
clientId={clientId}
/>
<FolderDialog
open={isFolderDialogOpen}
onOpenChange={setIsFolderDialogOpen}
editingFolder={editingFolder}
onSave={handleAddFolder}
/>
<MoveAgentDialog
open={isMovingDialogOpen}
onOpenChange={setIsMovingDialogOpen}
agent={agentToMove}
folders={folders}
onMove={handleMoveAgent}
/>
<ConfirmationDialog
open={isDeleteAgentDialogOpen}
onOpenChange={setIsDeleteAgentDialogOpen}
title="Confirm deletion"
description={`Are you sure you want to delete the agent "${agentToDelete?.name}"? This action cannot be undone.`}
onConfirm={handleDeleteAgent}
/>
<ConfirmationDialog
open={isDeleteFolderDialogOpen}
onOpenChange={setIsDeleteFolderDialogOpen}
title="Confirm deletion"
description={`Are you sure you want to delete the folder "${folderToDelete?.name}"? This action cannot be undone.`}
confirmText="Delete"
confirmVariant="destructive"
onConfirm={handleDeleteFolder}
/>
<ApiKeysDialog
open={isApiKeysDialogOpen}
onOpenChange={setIsApiKeysDialogOpen}
apiKeys={apiKeys}
isLoading={isLoading}
onAddApiKey={async (keyData) => {
await createApiKey({ ...keyData, client_id: clientId });
loadApiKeys();
}}
onUpdateApiKey={async (id, keyData) => {
await updateApiKey(id, keyData, clientId);
loadApiKeys();
}}
onDeleteApiKey={async (id) => {
await deleteApiKey(id, clientId);
loadApiKeys();
}}
/>
<ShareAgentDialog
open={isShareDialogOpen}
onOpenChange={setIsShareDialogOpen}
agent={agentToShare || ({} as Agent)}
apiKey={sharedApiKey}
/>
<ImportAgentDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
onSuccess={loadAgents}
clientId={clientId}
folderId={selectedFolderId}
/>
</div>
);
}

View File

@ -0,0 +1,689 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/Canva.tsx
Developed by: Davidson Gomes
Delay node integration developed by: Victor Calazans
Creation date: May 13, 2025 |
Delay implementation date: May 17, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import {
useState,
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import {
Controls,
ReactFlow,
addEdge,
useNodesState,
useEdgesState,
type OnConnect,
ConnectionMode,
ConnectionLineType,
useReactFlow,
ProOptions,
applyNodeChanges,
NodeChange,
OnNodesChange,
MiniMap,
Panel,
Background,
} from "@xyflow/react";
import { useDnD } from "@/contexts/DnDContext";
import { Edit, X, ChevronLeft, ChevronRight } from "lucide-react";
import "@xyflow/react/dist/style.css";
import "./canva.css";
import { getHelperLines } from "./utils";
import { NodePanel } from "./NodePanel";
import ContextMenu from "./ContextMenu";
import { initialEdges, edgeTypes } from "./edges";
import HelperLines from "./HelperLines";
import { initialNodes, nodeTypes } from "./nodes";
import { AgentForm } from "./nodes/components/agent/AgentForm";
import { ConditionForm } from "./nodes/components/condition/ConditionForm";
import { Agent, WorkflowData } from "@/types/agent";
import { updateAgent } from "@/services/agentService";
import { useToast } from "@/hooks/use-toast";
import { MessageForm } from "./nodes/components/message/MessageForm";
import { DelayForm } from "./nodes/components/delay/DelayForm";
import { Button } from "@/components/ui/button";
const proOptions: ProOptions = { account: "paid-pro", hideAttribution: true };
const NodeFormWrapper = ({
selectedNode,
editingLabel,
setEditingLabel,
handleUpdateNode,
setSelectedNode,
children,
}: {
selectedNode: any;
editingLabel: boolean;
setEditingLabel: (value: boolean) => void;
handleUpdateNode: (node: any) => void;
setSelectedNode: (node: any) => void;
children: React.ReactNode;
}) => {
// Handle ESC key to close the panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !editingLabel) {
setSelectedNode(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setSelectedNode, editingLabel]);
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0 sticky top-0 z-20 bg-neutral-800 shadow-md border-b border-neutral-700">
<div className="p-4 text-center relative">
<button
className="absolute right-2 top-2 text-neutral-200 hover:text-white p-1 rounded-full hover:bg-neutral-700"
onClick={() => setSelectedNode(null)}
aria-label="Close panel"
>
<X size={18} />
</button>
{!editingLabel ? (
<div className="flex items-center justify-center text-xl font-bold text-neutral-200">
<span>{selectedNode.data.label}</span>
{selectedNode.type !== "start-node" && (
<Edit
size={16}
className="ml-2 cursor-pointer hover:text-indigo-300"
onClick={() => setEditingLabel(true)}
/>
)}
</div>
) : (
<input
type="text"
value={selectedNode.data.label}
className="w-full p-2 text-center text-xl font-bold bg-neutral-800 text-neutral-200 border border-neutral-600 rounded"
onChange={(e) => {
handleUpdateNode({
...selectedNode,
data: {
...selectedNode.data,
label: e.target.value,
},
});
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setEditingLabel(false);
}
}}
onBlur={() => setEditingLabel(false)}
autoFocus
/>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
</div>
);
};
const Canva = forwardRef(({ agent }: { agent: Agent | null }, ref) => {
const { toast } = useToast();
const [nodes, setNodes] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { screenToFlowPosition } = useReactFlow();
const { type, setPointerEvents } = useDnD();
const [menu, setMenu] = useState<any>(null);
const localRef = useRef<any>(null);
const [selectedNode, setSelectedNode] = useState<any>(null);
const [activeExecutionNodeId, setActiveExecutionNodeId] = useState<
string | null
>(null);
const [editingLabel, setEditingLabel] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [nodePanelOpen, setNodePanelOpen] = useState(false);
useImperativeHandle(ref, () => ({
getFlowData: () => ({
nodes,
edges,
}),
setHasChanges,
setActiveExecutionNodeId,
}));
// Effect to clear the active node after a timeout
useEffect(() => {
if (activeExecutionNodeId) {
const timer = setTimeout(() => {
setActiveExecutionNodeId(null);
}, 5000); // Increase to 5 seconds to give more time to visualize
return () => clearTimeout(timer);
}
}, [activeExecutionNodeId]);
useEffect(() => {
if (
agent?.config?.workflow &&
agent.config.workflow.nodes.length > 0 &&
agent.config.workflow.edges.length > 0
) {
setNodes(
(agent.config.workflow.nodes as typeof initialNodes) || initialNodes
);
setEdges(
(agent.config.workflow.edges as typeof initialEdges) || initialEdges
);
} else {
setNodes(initialNodes);
setEdges(initialEdges);
}
}, [agent, setNodes, setEdges]);
// Update nodes when the active node changes to add visual class
useEffect(() => {
if (nodes.length > 0) {
setNodes((nds: any) =>
nds.map((node: any) => {
if (node.id === activeExecutionNodeId) {
// Add a class to highlight the active node
return {
...node,
className: "active-execution-node",
data: {
...node.data,
isExecuting: true,
},
};
} else {
// Remove the highlight class
const { isExecuting, ...restData } = node.data || {};
return {
...node,
className: "",
data: restData,
};
}
})
);
}
}, [activeExecutionNodeId, setNodes]);
useEffect(() => {
if (agent?.config?.workflow) {
const initialNodes = agent.config.workflow.nodes || [];
const initialEdges = agent.config.workflow.edges || [];
if (
JSON.stringify(nodes) !== JSON.stringify(initialNodes) ||
JSON.stringify(edges) !== JSON.stringify(initialEdges)
) {
setHasChanges(true);
} else {
setHasChanges(false);
}
}
}, [nodes, edges, agent]);
const [helperLineHorizontal, setHelperLineHorizontal] = useState<
number | undefined
>(undefined);
const [helperLineVertical, setHelperLineVertical] = useState<
number | undefined
>(undefined);
const onConnect: OnConnect = useCallback(
(connection) => {
setEdges((currentEdges) => {
if (connection.source === connection.target) {
return currentEdges;
}
return addEdge(connection, currentEdges);
});
},
[setEdges]
);
const onConnectEnd = useCallback(
(_event: any, connectionState: any) => {
setPointerEvents("none");
if (connectionState.fromHandle?.type === "target") {
return;
}
if (!connectionState.isValid) {
// Since we're using NodePanel now, we don't need to do anything here
// The panel will handle node creation through drag and drop
}
},
[setPointerEvents]
);
const onConnectStart = useCallback(() => {
setPointerEvents("auto");
}, [setPointerEvents]);
const customApplyNodeChanges = useCallback(
(changes: NodeChange[], nodes: any): any => {
// reset the helper lines (clear existing lines, if any)
setHelperLineHorizontal(undefined);
setHelperLineVertical(undefined);
// this will be true if it's a single node being dragged
// inside we calculate the helper lines and snap position for the position where the node is being moved to
if (
changes.length === 1 &&
changes[0].type === "position" &&
changes[0].dragging &&
changes[0].position
) {
const helperLines = getHelperLines(changes[0], nodes);
// if we have a helper line, we snap the node to the helper line position
// this is being done by manipulating the node position inside the change object
changes[0].position.x =
helperLines.snapPosition.x ?? changes[0].position.x;
changes[0].position.y =
helperLines.snapPosition.y ?? changes[0].position.y;
// if helper lines are returned, we set them so that they can be displayed
setHelperLineHorizontal(helperLines.horizontal);
setHelperLineVertical(helperLines.vertical);
}
return applyNodeChanges(changes, nodes);
},
[]
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => {
setNodes((nodes) => customApplyNodeChanges(changes, nodes));
},
[setNodes, customApplyNodeChanges]
);
const getLabelFromNode = (type: string) => {
const order = nodes.length;
switch (type) {
case "start-node":
return "Start";
case "agent-node":
return `Agent #${order}`;
case "condition-node":
return `Condition #${order}`;
case "message-node":
return `Message #${order}`;
case "delay-node":
return `Delay #${order}`;
default:
return "Node";
}
};
const handleAddNode = useCallback(
(type: any, node: any) => {
const newNode: any = {
id: String(Date.now()),
type,
position: node.position,
data: {
label: getLabelFromNode(type),
},
};
setNodes((nodes) => [...nodes, newNode]);
if (node.targetId) {
const newEdge: any = {
source: node.targetId,
sourceHandle: node.handleId,
target: newNode.id,
type: "default",
};
const newsEdges: any = [...edges, newEdge];
setEdges(newsEdges);
}
},
[nodes, setNodes, edges, setEdges]
);
const handleUpdateNode = useCallback(
(node: any) => {
setNodes((nodes) => {
const index = nodes.findIndex((n) => n.id === node.id);
if (index !== -1) {
nodes[index] = node;
}
return [...nodes];
});
if (selectedNode && selectedNode.id === node.id) {
setSelectedNode(node);
}
},
[setNodes, selectedNode]
);
const handleDeleteEdge = useCallback(
(id: any) => {
setEdges((edges) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
},
[setEdges]
);
const onDragOver = useCallback((event: any) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: any) => {
event.preventDefault();
if (!type) {
return;
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode: any = {
id: String(Date.now()),
type,
position,
data: {
label: getLabelFromNode(type),
},
};
setNodes((nodes) => [...nodes, newNode]);
},
[screenToFlowPosition, setNodes, type, getLabelFromNode]
);
const onNodeContextMenu = useCallback(
(event: any, node: any) => {
event.preventDefault();
if (node.id === "start-node") {
return;
}
if (!localRef.current) {
return;
}
const paneBounds = localRef.current.getBoundingClientRect();
const x = event.clientX - paneBounds.left;
const y = event.clientY - paneBounds.top;
const menuWidth = 200;
const menuHeight = 200;
const left = x + menuWidth > paneBounds.width ? undefined : x;
const top = y + menuHeight > paneBounds.height ? undefined : y;
const right =
x + menuWidth > paneBounds.width ? paneBounds.width - x : undefined;
const bottom =
y + menuHeight > paneBounds.height ? paneBounds.height - y : undefined;
setMenu({
id: node.id,
left,
top,
right,
bottom,
});
},
[setMenu]
);
const onNodeClick = useCallback((event: any, node: any) => {
event.preventDefault();
if (node.type === "start-node") {
return;
}
setSelectedNode(node);
}, []);
const onPaneClick = useCallback(() => {
setMenu(null);
setSelectedNode(null);
setNodePanelOpen(false);
}, [setMenu, setSelectedNode]);
return (
<div className="h-full w-full bg-[#121212]">
<div
style={{ position: "relative", height: "100%", width: "100%" }}
ref={localRef}
className="overflow-hidden"
>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
edges={edges}
edgeTypes={edgeTypes}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
connectionMode={ConnectionMode.Strict}
connectionLineType={ConnectionLineType.Bezier}
onDrop={onDrop}
onDragOver={onDragOver}
onPaneClick={onPaneClick}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
colorMode="dark"
minZoom={0.1}
maxZoom={10}
fitView={false}
defaultViewport={{
x: 0,
y: 0,
zoom: 1,
}}
elevateEdgesOnSelect
elevateNodesOnSelect
proOptions={proOptions}
connectionLineStyle={{
stroke: "gray",
strokeWidth: 2,
strokeDashoffset: 5,
strokeDasharray: 5,
}}
defaultEdgeOptions={{
type: "default",
style: {
strokeWidth: 3,
},
data: {
handleDeleteEdge,
},
}}
>
<Background color="#334155" gap={24} size={1.5} />
<MiniMap
className="bg-neutral-800/80 border border-neutral-700 rounded-lg shadow-lg"
nodeColor={(node) => {
switch (node.type) {
case "start-node":
return "#10b981";
case "agent-node":
return "#3b82f6";
case "message-node":
return "#f97316";
case "condition-node":
return "#3b82f6";
case "delay-node":
return "#eab308";
default:
return "#64748b";
}
}}
maskColor="rgba(15, 23, 42, 0.6)"
/>
<Controls
showInteractive={true}
showFitView={true}
orientation="vertical"
position="bottom-left"
/>
<HelperLines
horizontal={helperLineHorizontal}
vertical={helperLineVertical}
/>
{menu && <ContextMenu onClick={onPaneClick} {...menu} />}
{nodePanelOpen ? (
<Panel position="top-right">
<div className="flex items-start">
<Button
variant="outline"
size="icon"
onClick={() => setNodePanelOpen(false)}
className="mr-2 h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
>
<ChevronRight className="h-4 w-4" />
</Button>
<NodePanel />
</div>
</Panel>
) : (
<Panel position="top-right">
<Button
variant="outline"
size="icon"
onClick={() => setNodePanelOpen(true)}
className="h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</Panel>
)}
</ReactFlow>
{/* Overlay when form is open on smaller screens */}
{selectedNode && (
<div
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-[5] transition-opacity duration-300"
onClick={() => setSelectedNode(null)}
/>
)}
<div
className="absolute left-0 top-0 z-10 h-full w-[350px] bg-neutral-900 shadow-lg transition-all duration-300 ease-in-out border-r border-neutral-700 flex flex-col"
style={{
transform: selectedNode ? "translateX(0)" : "translateX(-100%)",
opacity: selectedNode ? 1 : 0,
}}
>
{selectedNode ? (
<NodeFormWrapper
selectedNode={selectedNode}
editingLabel={editingLabel}
setEditingLabel={setEditingLabel}
handleUpdateNode={handleUpdateNode}
setSelectedNode={setSelectedNode}
>
{selectedNode.type === "agent-node" && (
<AgentForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "condition-node" && (
<ConditionForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "message-node" && (
<MessageForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "delay-node" && (
<DelayForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
</NodeFormWrapper>
) : null}
</div>
</div>
</div>
);
});
Canva.displayName = "Canva";
export default Canva;

View File

@ -0,0 +1,118 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/ContextMenu.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useReactFlow, Node, Edge } from "@xyflow/react";
import { Copy, Trash2 } from "lucide-react";
import React, { useCallback } from "react";
interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
id: string;
top?: number | string;
left?: number | string;
right?: number | string;
bottom?: number | string;
}
export default function ContextMenu({
id,
top,
left,
right,
bottom,
...props
}: ContextMenuProps) {
const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
const duplicateNode = useCallback(() => {
const node = getNode(id);
if (!node) {
console.error(`Node with id ${id} not found.`);
return;
}
const position = {
x: node.position.x + 50,
y: node.position.y + 50,
};
addNodes({
...node,
id: `${node.id}-copy`,
position,
selected: false,
dragging: false,
});
}, [id, getNode, addNodes]);
const deleteNode = useCallback(() => {
setNodes((nodes: Node[]) => nodes.filter((node) => node.id !== id));
setEdges((edges: Edge[]) =>
edges.filter((edge) => edge.source !== id && edge.target !== id),
);
}, [id, setNodes, setEdges]);
return (
<div
style={{
position: "absolute",
top: top !== undefined ? `${top}px` : undefined,
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
bottom: bottom !== undefined ? `${bottom}px` : undefined,
zIndex: 10,
}}
className="context-menu rounded-md border p-3 shadow-lg border-neutral-700 bg-neutral-800"
{...props}
>
<p className="mb-2 text-sm font-semibold text-neutral-200">
Actions
</p>
<button
onClick={duplicateNode}
className="mb-1 flex w-full flex-row items-center rounded-md px-2 py-1 text-sm hover:bg-neutral-700"
>
<Copy
size={16}
className="mr-2 flex-shrink-0 text-blue-300"
/>
<span className="text-neutral-300">Duplicate</span>
</button>
<button
onClick={deleteNode}
className="flex w-full flex-row items-center rounded-md px-2 py-1 text-sm hover:bg-neutral-700"
>
<Trash2
size={16}
className="mr-2 flex-shrink-0 text-red-300"
/>
<span className="text-neutral-300">Delete</span>
</button>
</div>
);
}

View File

@ -0,0 +1,98 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/HelperLines.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { ReactFlowState, useStore } from "@xyflow/react";
import { CSSProperties, useEffect, useRef } from "react";
const canvasStyle: CSSProperties = {
width: "100%",
height: "100%",
position: "absolute",
zIndex: 10,
pointerEvents: "none",
};
const storeSelector = (state: ReactFlowState) => ({
width: state.width,
height: state.height,
transform: state.transform,
});
export type HelperLinesProps = {
horizontal?: number;
vertical?: number;
};
// a simple component to display the helper lines
// it puts a canvas on top of the React Flow pane and draws the lines using the canvas API
function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) {
const { width, height, transform } = useStore(storeSelector);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!ctx || !canvas) {
return;
}
const dpi = window.devicePixelRatio;
canvas.width = width * dpi;
canvas.height = height * dpi;
ctx.scale(dpi, dpi);
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = "#1d5ade";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
if (typeof vertical === "number") {
ctx.moveTo(vertical * transform[2] + transform[0], 0);
ctx.lineTo(vertical * transform[2] + transform[0], height);
ctx.stroke();
}
if (typeof horizontal === "number") {
ctx.moveTo(0, horizontal * transform[2] + transform[1]);
ctx.lineTo(width, horizontal * transform[2] + transform[1]);
ctx.stroke();
}
}, [width, height, transform, horizontal, vertical]);
return (
<canvas
ref={canvasRef}
className="react-flow__canvas"
style={canvasStyle}
/>
);
}
export default HelperLinesRenderer;

View File

@ -0,0 +1,273 @@
"use client";
import type React from "react";
import { useState } from "react";
import {
User,
MessageSquare,
Filter,
Clock,
Plus,
MenuSquare,
Layers,
MoveRight,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDnD } from "@/contexts/DnDContext";
export function NodePanel() {
const [activeTab, setActiveTab] = useState("content");
const { setType } = useDnD();
const nodeTypes = {
content: [
{
id: "agent-node",
name: "Agent",
icon: User,
color: "text-blue-400",
bgColor: "bg-blue-950/40",
borderColor: "border-blue-500/30",
hoverColor: "group-hover:bg-blue-900/50",
glowColor: "group-hover:shadow-blue-500/20",
description: "Add an AI agent to process messages and execute tasks",
},
{
id: "message-node",
name: "Message",
icon: MessageSquare,
color: "text-orange-400",
bgColor: "bg-orange-950/40",
borderColor: "border-orange-500/30",
hoverColor: "group-hover:bg-orange-900/50",
glowColor: "group-hover:shadow-orange-500/20",
description: "Send a message to users or other nodes in the workflow",
},
],
logic: [
{
id: "condition-node",
name: "Condition",
icon: Filter,
color: "text-purple-400",
bgColor: "bg-purple-950/40",
borderColor: "border-purple-500/30",
hoverColor: "group-hover:bg-purple-900/50",
glowColor: "group-hover:shadow-purple-500/20",
description:
"Create a decision point with multiple outcomes based on conditions",
},
{
id: "delay-node",
name: "Delay",
icon: Clock,
color: "text-yellow-400",
bgColor: "bg-yellow-950/40",
borderColor: "border-yellow-500/30",
hoverColor: "group-hover:bg-yellow-900/50",
glowColor: "group-hover:shadow-yellow-500/20",
description: "Add a time delay between workflow operations",
},
],
};
const onDragStart = (event: React.DragEvent, nodeType: string) => {
event.dataTransfer.setData("application/reactflow", nodeType);
event.dataTransfer.effectAllowed = "move";
setType(nodeType);
};
const handleNodeAdd = (nodeType: string) => {
setType(nodeType);
};
return (
<div className="bg-slate-900/70 backdrop-blur-md border border-slate-700/50 rounded-xl shadow-xl w-[320px] transition-all duration-300 ease-in-out overflow-hidden">
<div className="px-4 pt-4 pb-2 border-b border-slate-700/50">
<div className="flex items-center gap-2 text-slate-200">
<Layers className="h-5 w-5 text-indigo-400" />
<h3 className="font-medium">Workflow Nodes</h3>
</div>
<p className="text-xs text-slate-400 mt-1">
Drag nodes to the canvas or click to add
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="px-4 pt-3">
<TabsList className="w-full bg-slate-800/50 grid grid-cols-2 p-1 rounded-lg">
<TabsTrigger
value="content"
className={cn(
"rounded-md text-xs font-medium transition-all",
"data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-900/30 data-[state=active]:to-indigo-900/30",
"data-[state=active]:text-blue-300 data-[state=active]:shadow-sm",
"data-[state=inactive]:text-slate-400"
)}
>
<MenuSquare className="h-3.5 w-3.5 mr-1.5" />
Content
</TabsTrigger>
<TabsTrigger
value="logic"
className={cn(
"rounded-md text-xs font-medium transition-all",
"data-[state=active]:bg-gradient-to-br data-[state=active]:from-yellow-900/30 data-[state=active]:to-orange-900/30",
"data-[state=active]:text-yellow-300 data-[state=active]:shadow-sm",
"data-[state=inactive]:text-slate-400"
)}
>
<Filter className="h-3.5 w-3.5 mr-1.5" />
Logic
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="content" className="p-3 space-y-2 mt-0">
{nodeTypes.content.map((node) => (
<TooltipProvider key={node.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
draggable
onDragStart={(event) => onDragStart(event, node.id)}
className={cn(
"group flex items-center gap-3 p-3.5 border rounded-lg cursor-grab transition-all duration-300",
"backdrop-blur-sm hover:shadow-lg",
node.borderColor,
node.bgColor,
node.glowColor
)}
>
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
"bg-slate-800/80 group-hover:scale-105",
node.hoverColor
)}
>
<node.icon className={cn("h-5 w-5", node.color)} />
</div>
<div className="flex-1 min-w-0">
<span
className={cn("font-medium block text-sm", node.color)}
>
{node.name}
</span>
<span className="text-xs text-slate-400 truncate block">
{node.description}
</span>
</div>
<div
onClick={() => handleNodeAdd(node.id)}
className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-slate-800/60 text-slate-400",
"hover:bg-gradient-to-r hover:text-white transition-all",
node.id === "agent-node"
? "hover:from-blue-800 hover:to-blue-600"
: "hover:from-orange-800 hover:to-orange-600"
)}
>
<Plus size={16} />
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200"
>
<div className="p-1 max-w-[200px]">
<p className="font-medium text-sm">{node.name} Node</p>
<p className="text-xs text-slate-400 mt-1">
{node.description}
</p>
<div className="flex items-center mt-2 pt-2 border-t border-slate-700/50 text-xs text-slate-400">
<MoveRight className="h-3 w-3 mr-1.5" />
<span>Drag to canvas or click + to add</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</TabsContent>
<TabsContent value="logic" className="p-3 space-y-2 mt-0">
{nodeTypes.logic.map((node) => (
<TooltipProvider key={node.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
draggable
onDragStart={(event) => onDragStart(event, node.id)}
className={cn(
"group flex items-center gap-3 p-3.5 border rounded-lg cursor-grab transition-all duration-300",
"backdrop-blur-sm hover:shadow-lg",
node.borderColor,
node.bgColor,
node.glowColor
)}
>
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
"bg-slate-800/80 group-hover:scale-105",
node.hoverColor
)}
>
<node.icon className={cn("h-5 w-5", node.color)} />
</div>
<div className="flex-1 min-w-0">
<span
className={cn("font-medium block text-sm", node.color)}
>
{node.name}
</span>
<span className="text-xs text-slate-400 truncate block">
{node.description}
</span>
</div>
<div
onClick={() => handleNodeAdd(node.id)}
className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-slate-800/60 text-slate-400",
"hover:bg-gradient-to-r hover:text-white transition-all",
node.id === "condition-node"
? "hover:from-purple-800 hover:to-purple-600"
: "hover:from-yellow-800 hover:to-yellow-600"
)}
>
<Plus size={16} />
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200"
>
<div className="p-1 max-w-[200px]">
<p className="font-medium text-sm">{node.name} Node</p>
<p className="text-xs text-slate-400 mt-1">
{node.description}
</p>
<div className="flex items-center mt-2 pt-2 border-t border-slate-700/50 text-xs text-slate-400">
<MoveRight className="h-3 w-3 mr-1.5" />
<span>Drag to canvas or click + to add</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,188 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/canva.css
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
.react-flow.dark {
--xy-background-color-default: transparent;
}
.react-flow__panel {
box-shadow: none;
padding: 5px;
}
.react-flow__controls {
background-color: rgba(30, 30, 30, 0.8);
backdrop-filter: blur(4px);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(70, 70, 70, 0.5);
padding: 4px;
}
.react-flow__controls-button {
padding: 6px;
width: 32px;
height: 32px;
margin: 4px 0;
border: none !important;
background-color: rgba(40, 40, 40, 0.8) !important;
color: #a0a0a0 !important;
border-radius: 6px !important;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.react-flow__controls-button:hover {
background-color: rgba(60, 60, 60, 0.9) !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.react-flow__controls-button svg {
fill: currentColor;
width: 16px;
height: 16px;
}
.react-flow__controls-button[data-action="zoom-in"] {
border-bottom: 1px solid rgba(70, 70, 70, 0.5) !important;
}
.react-flow__controls-button[data-action="zoom-out"] {
border-bottom: 1px solid rgba(70, 70, 70, 0.5) !important;
}
/* Hide scrollbar for all browsers while maintaining scroll functionality */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
width: 0;
height: 0;
background: transparent;
}
/* Animated dashed edge for flow edges */
.edge-dashed-animated {
stroke-dasharray: 6;
stroke-dashoffset: 0;
animation: dash 0.5s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -12;
}
}
/* Edges styling */
.react-flow__edge-path {
stroke: #10b981; /* Emerald color for edges */
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #3b82f6; /* Blue color for selected edges */
stroke-width: 3;
}
/* Node handle styling */
.react-flow__handle {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #10b981;
border: 2px solid #0d9488;
}
.react-flow__handle:hover {
background-color: #3b82f6;
border-color: #2563eb;
}
/* Nó em execução */
.active-execution-node {
animation: pulse-execution 1.5s infinite;
filter: drop-shadow(0 0 15px rgba(5, 212, 114, 0.9));
z-index: 10;
transform: scale(1.03);
transition: transform 0.3s ease-in-out;
}
@keyframes pulse-execution {
0% {
box-shadow: 0 0 0 0 rgba(5, 212, 114, 0.9);
}
70% {
box-shadow: 0 0 0 15px rgba(5, 212, 114, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(5, 212, 114, 0);
}
}
.react-flow__node[data-is-executing="true"] {
filter: drop-shadow(0 0 15px rgba(5, 212, 114, 0.9));
transform: scale(1.03);
transition: transform 0.3s ease-in-out;
}
.react-flow__node[data-is-executing="true"]::before {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border: 2px solid #05d472;
border-radius: 8px;
animation: pulse-border 1.5s infinite;
z-index: -1;
}
@keyframes pulse-border {
0% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0.3;
transform: scale(1.03);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@ -0,0 +1,119 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/edges/DefaultEdge.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
useReactFlow,
} from "@xyflow/react";
import { Trash2 } from "lucide-react";
export default function DefaultEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
selected,
}: EdgeProps) {
const { setEdges } = useReactFlow();
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 15,
});
const onEdgeClick = () => {
console.log("onEdgeClick", id);
setEdges((edges) => edges.filter((edge) => edge.id !== id));
};
return (
<>
<svg>
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 16"
refX="12"
refY="8"
markerWidth="4"
markerHeight="5"
orient="auto"
>
<path d="M 0 0 L 10 8 L 0 16 z" fill="gray" />
</marker>
</defs>
</svg>
<BaseEdge
id={id}
path={edgePath}
className="edge-dashed-animated"
style={{
...style,
stroke: '#10B981',
strokeWidth: 3,
}}
markerEnd="url(#arrowhead)"
/>
{selected && (
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
pointerEvents: "all",
zIndex: 1000,
}}
className="nodrag nopan"
>
<button
className="rounded-full bg-white p-1 shadow-md"
onClick={onEdgeClick}
>
<Trash2 className="text-red-500" size={16} />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@ -0,0 +1,41 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/edges/index.ts
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import type { Edge, EdgeTypes } from "@xyflow/react";
import DefaultEdge from "./DefaultEdge";
export const initialEdges = [
// { id: "a->c", source: "a", target: "c", animated: true },
// { id: "b->d", source: "b", target: "d" },
// { id: "c->d", source: "c", target: "d", animated: true },
] satisfies Edge[];
export const edgeTypes = {
default: DefaultEdge,
} satisfies EdgeTypes;

View File

@ -0,0 +1,166 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/BaseNode.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Handle, Position } from "@xyflow/react";
import React from "react";
import { cn } from "@/lib/utils";
import { useDnD } from "@/contexts/DnDContext";
export function BaseNode({
selected,
hasTarget,
children,
borderColor,
isExecuting
}: {
selected: boolean;
hasTarget: boolean;
children: React.ReactNode;
borderColor: string;
isExecuting?: boolean;
}) {
const { pointerEvents } = useDnD();
// Border and background color mapping
const colorStyles = {
blue: {
border: "border-blue-700/70 hover:border-blue-500",
gradient: "bg-gradient-to-br from-blue-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(59,130,246,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(59,130,246,0.3)]"
},
orange: {
border: "border-orange-700/70 hover:border-orange-500",
gradient: "bg-gradient-to-br from-orange-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(249,115,22,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(249,115,22,0.3)]"
},
green: {
border: "border-green-700/70 hover:border-green-500",
gradient: "bg-gradient-to-br from-green-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(34,197,94,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(34,197,94,0.3)]"
},
red: {
border: "border-red-700/70 hover:border-red-500",
gradient: "bg-gradient-to-br from-red-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(239,68,68,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(239,68,68,0.3)]"
},
yellow: {
border: "border-yellow-700/70 hover:border-yellow-500",
gradient: "bg-gradient-to-br from-yellow-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(234,179,8,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(234,179,8,0.3)]"
},
purple: {
border: "border-purple-700/70 hover:border-purple-500",
gradient: "bg-gradient-to-br from-purple-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(168,85,247,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(168,85,247,0.3)]"
},
indigo: {
border: "border-indigo-700/70 hover:border-indigo-500",
gradient: "bg-gradient-to-br from-indigo-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(99,102,241,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(99,102,241,0.3)]"
},
pink: {
border: "border-pink-700/70 hover:border-pink-500",
gradient: "bg-gradient-to-br from-pink-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(236,72,153,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(236,72,153,0.3)]"
},
emerald: {
border: "border-emerald-700/70 hover:border-emerald-500",
gradient: "bg-gradient-to-br from-emerald-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(16,185,129,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(16,185,129,0.3)]"
},
slate: {
border: "border-slate-700/70 hover:border-slate-500",
gradient: "bg-gradient-to-br from-slate-800/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(100,116,139,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(100,116,139,0.3)]"
},
};
// Default to blue if color not in mapping
const colorStyle = colorStyles[borderColor as keyof typeof colorStyles] || colorStyles.blue;
// Selected styles
const selectedStyle = {
border: "border-green-500/90",
glow: colorStyle.selectedGlow
};
// Executing styles
const executingStyle = {
border: "border-emerald-500",
glow: "shadow-[0_0_25px_rgba(5,212,114,0.5)]"
};
return (
<>
<div
className={cn(
"relative z-0 w-[350px] rounded-2xl p-4 border-2 backdrop-blur-sm transition-all duration-300",
"shadow-lg hover:shadow-xl",
isExecuting ? executingStyle.glow : selected ? selectedStyle.glow : colorStyle.glow,
isExecuting ? executingStyle.border : selected ? selectedStyle.border : colorStyle.border,
colorStyle.gradient,
isExecuting && "active-execution-node"
)}
style={{
backdropFilter: "blur(12px)",
}}
data-is-executing={isExecuting ? "true" : "false"}
>
{hasTarget && (
<Handle
style={{
position: "absolute",
left: "50%",
top: "50%",
width: "100%",
borderRadius: "15px",
height: "100%",
backgroundColor: "transparent",
border: "none",
pointerEvents: pointerEvents === "none" ? "none" : "auto",
}}
type="target"
position={Position.Left}
/>
)}
{children}
</div>
</>
);
}

View File

@ -0,0 +1,347 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/agent/AgentChatMessageList.tsx
Developed by: Davidson Gomes
Creation date: May 14, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React, { useEffect, useRef } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useState } from "react";
import { Agent } from "@/types/agent";
import { InlineDataAttachments } from "@/app/chat/components/InlineDataAttachments";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatMessage {
id: string;
content: {
parts: any[];
role: string;
};
author: string;
timestamp: number;
}
interface AgentChatMessageListProps {
messages: ChatMessage[];
agent: Agent;
expandedFunctions: Record<string, boolean>;
toggleFunctionExpansion: (messageId: string) => void;
getMessageText: (message: ChatMessage) => string | FunctionMessageContent;
containsMarkdown: (text: string) => boolean;
}
export function AgentChatMessageList({
messages,
agent,
expandedFunctions,
toggleFunctionExpansion,
getMessageText,
containsMarkdown,
}: AgentChatMessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom whenever messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
return (
<div className="space-y-6">
{messages.map((message) => {
const messageContent = getMessageText(message);
const isExpanded = expandedFunctions[message.id] || false;
const isUser = message.author === "user";
const agentColor = "bg-emerald-400";
const hasFunctionCall = message.content.parts.some(
(part) => part.functionCall || part.function_call
);
const hasFunctionResponse = message.content.parts.some(
(part) => part.functionResponse || part.function_response
);
const isFunctionMessage = hasFunctionCall || hasFunctionResponse;
const isTaskExecutor = typeof messageContent === "object" &&
"author" in messageContent &&
typeof messageContent.author === "string" &&
messageContent.author.endsWith("- Task executor");
const inlineDataParts = message.content.parts.filter(part => part.inline_data);
const hasInlineData = inlineDataParts.length > 0;
const isWorkflowNode = message.author && message.author.startsWith('workflow-node:');
const nodeId = isWorkflowNode ? message.author.split(':')[1] : null;
return (
<div
key={message.id}
className="flex w-full"
style={{
justifyContent: isUser ? "flex-end" : "flex-start"
}}
>
<div
className="flex gap-3 max-w-[90%]"
style={{
flexDirection: isUser ? "row-reverse" : "row"
}}
>
<Avatar className={isUser ? "bg-[#333]" : agentColor}>
<AvatarFallback>
{isUser ? "U" : agent.name[0] || "A"}
</AvatarFallback>
</Avatar>
<div
className={`rounded-lg p-3 ${isFunctionMessage || isTaskExecutor
? "bg-[#333] text-emerald-400 font-mono text-sm"
: isUser
? "bg-emerald-400 text-black"
: "bg-[#222] text-white"
} overflow-hidden relative group`}
style={{
wordBreak: "break-word",
maxWidth: "calc(100% - 3rem)",
width: "100%",
...(isWorkflowNode ? {
borderLeft: '3px solid #05d472',
boxShadow: '0 0 10px rgba(5, 212, 114, 0.2)'
} : {})
}}
>
{isWorkflowNode && (
<div className="text-xs text-emerald-500 mb-1 flex items-center space-x-1 bg-emerald-950/30 p-1 rounded">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3 mr-1">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
<span>Node {nodeId} is running</span>
</div>
)}
{isFunctionMessage || isTaskExecutor ? (
<div className="w-full">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-[#444] rounded px-1 py-0.5 transition-colors"
onClick={() => toggleFunctionExpansion(message.id)}
>
{typeof messageContent === "object" &&
"title" in messageContent && (
<>
<div className="flex-1 font-semibold">
{(messageContent as FunctionMessageContent).title}
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
{isTaskExecutor && (
<>
<div className="flex-1 font-semibold">
Task Execution
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
</div>
{isExpanded && (
<div className="mt-2 pt-2 border-t border-[#555]">
{typeof messageContent === "object" &&
"content" in messageContent && (
<div className="max-w-full overflow-x-auto">
<pre className="whitespace-pre-wrap text-xs max-w-full" style={{
wordWrap: "break-word",
maxWidth: "100%",
wordBreak: "break-all"
}}>
{(messageContent as FunctionMessageContent).content}
</pre>
</div>
)}
</div>
)}
</div>
) : (
<div className="markdown-content break-words max-w-full overflow-x-auto">
{typeof messageContent === "object" &&
"author" in messageContent &&
messageContent.author !== "user" &&
!isTaskExecutor && (
<div className="text-xs text-neutral-400 mb-1">
{messageContent.author}
</div>
)}
{((typeof messageContent === "string" &&
containsMarkdown(messageContent)) ||
(typeof messageContent === "object" &&
"content" in messageContent &&
typeof messageContent.content === "string" &&
containsMarkdown(messageContent.content))) &&
!isTaskExecutor ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1 className="text-xl font-bold my-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-lg font-bold my-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-base font-bold my-2" {...props} />
),
h4: ({ ...props }) => (
<h4 className="font-semibold my-2" {...props} />
),
p: ({ ...props }) => <p className="mb-3" {...props} />,
ul: ({ ...props }) => (
<ul
className="list-disc pl-6 mb-3 space-y-1"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal pl-6 mb-3 space-y-1"
{...props}
/>
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
a: ({ ...props }) => (
<a
className="text-emerald-400 underline hover:opacity-80 transition-opacity"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-[#444] pl-4 py-1 italic my-3 text-neutral-300"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match && typeof children === "string" && !children.includes("\n");
if (isInline) {
return (
<code
className="bg-[#333] px-1.5 py-0.5 rounded text-emerald-400 text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<div className="my-3 relative">
<div className="bg-[#1a1a1a] rounded-t-md border-b border-[#333] p-2 text-xs text-neutral-400">
<span>{match?.[1] || "Code"}</span>
</div>
<pre className="bg-[#1a1a1a] p-3 rounded-b-md overflow-x-auto whitespace-pre text-sm">
<code {...props}>{children}</code>
</pre>
</div>
);
},
table: ({ ...props }) => (
<div className="overflow-x-auto my-3">
<table
className="min-w-full border border-[#333] rounded"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead className="bg-[#1a1a1a]" {...props} />
),
tbody: ({ ...props }) => <tbody {...props} />,
tr: ({ ...props }) => (
<tr
className="border-b border-[#333] last:border-0"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-2 text-left text-xs font-semibold text-neutral-300"
{...props}
/>
),
td: ({ ...props }) => (
<td className="px-4 py-2 text-sm" {...props} />
),
}}
>
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap">
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</div>
)}
{hasInlineData && (
<InlineDataAttachments parts={inlineDataParts} />
)}
</div>
)}
</div>
</div>
</div>
);
})}
{/* Empty div at the end for auto-scrolling */}
<div ref={messagesEndRef} />
</div>
);
}

View File

@ -0,0 +1,631 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/agent/AgentForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable jsx-a11y/alt-text */
import { useEdges, useNodes } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Agent } from "@/types/agent";
import { listAgents, listFolders, Folder, getAgent } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { User, Loader2, Search, FolderIcon, Trash2, Play, MessageSquare, PlayIcon, Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { AgentForm as GlobalAgentForm } from "@/app/agents/forms/AgentForm";
import { ApiKey, listApiKeys } from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { availableModels } from "@/types/aiModels";
import { MCPServer } from "@/types/mcpServer";
import { AgentTestChatModal } from "./AgentTestChatModal";
import { sanitizeAgentName, escapePromptBraces } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/* eslint-disable @typescript-eslint/no-explicit-any */
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || '{}') : {};
const clientId: string = user?.client_id ? String(user.client_id) : "";
const agentListStyles = {
scrollbarWidth: 'none', /* Firefox */
msOverflowStyle: 'none', /* IE and Edge */
'::-webkit-scrollbar': {
display: 'none' /* Chrome, Safari and Opera */
}
};
export function AgentForm({ selectedNode, handleUpdateNode, setEdges, setIsOpen, setSelectedNode }: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [loading, setLoading] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(true);
const [loadingCurrentAgent, setLoadingCurrentAgent] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [allAgents, setAllAgents] = useState<Agent[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedAgentType, setSelectedAgentType] = useState<string | null>(null);
const [agentTypes, setAgentTypes] = useState<string[]>([]);
const [agentFolderId, setAgentFolderId] = useState<string | null>(null);
const edges = useEdges();
const nodes = useNodes();
const [isAgentDialogOpen, setIsAgentDialogOpen] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [newAgent, setNewAgent] = useState<Partial<Agent>>({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: { http_tools: [] },
sub_agents: [],
agent_tools: [],
},
});
const [isLoading, setIsLoading] = useState(false);
const [isTestModalOpen, setIsTestModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
// Access the canvas reference from localStorage
const canvasRef = useRef<any>(null);
useEffect(() => {
// When the component is mounted, check if there is a canvas reference in the global context
if (typeof window !== "undefined") {
const workflowsPage = document.querySelector('[data-workflow-page="true"]');
if (workflowsPage) {
// If we are on the workflows page, try to access the canvas ref
const canvasElement = workflowsPage.querySelector('[data-canvas-ref="true"]');
if (canvasElement && (canvasElement as any).__reactRef) {
canvasRef.current = (canvasElement as any).__reactRef.current;
}
}
}
}, []);
const connectedNode = useMemo(() => {
const edge = edges.find((edge: any) => edge.source === selectedNode.id);
if (!edge) return null;
const node = nodes.find((node: any) => node.id === edge.target);
return node || null;
}, [edges, nodes, selectedNode.id]);
const currentAgent = typeof window !== "undefined" ?
JSON.parse(localStorage.getItem("current_workflow_agent") || '{}') : {};
const currentAgentId = currentAgent?.id;
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
}
}, [selectedNode]);
useEffect(() => {
if (!clientId) return;
setLoadingFolders(true);
listFolders(clientId)
.then((res) => {
setFolders(res.data);
})
.catch((error) => console.error("Error loading folders:", error))
.finally(() => setLoadingFolders(false));
}, [clientId]);
useEffect(() => {
if (!currentAgentId || !clientId) {
return;
}
setLoadingCurrentAgent(true);
getAgent(currentAgentId, clientId)
.then((res) => {
const agent = res.data;
if (agent.folder_id) {
setAgentFolderId(agent.folder_id);
setSelectedFolderId(agent.folder_id);
}
})
.catch((error) => console.error("Error loading current agent:", error))
.finally(() => setLoadingCurrentAgent(false));
}, [currentAgentId, clientId]);
useEffect(() => {
if (!clientId) return;
if (loadingFolders || loadingCurrentAgent) return;
setLoading(true);
listAgents(clientId, 0, 100, selectedFolderId || undefined)
.then((res) => {
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
// Extract unique agent types
const types = [...new Set(filteredAgents.map(agent => agent.type))].filter(Boolean);
setAgentTypes(types);
})
.catch((error) => console.error("Error loading agents:", error))
.finally(() => setLoading(false));
}, [clientId, currentAgentId, selectedFolderId, loadingFolders, loadingCurrentAgent]);
useEffect(() => {
// Apply all filters: search, folder, and type
let filtered = allAgents;
// Search filter is applied in a separate effect
if (searchQuery.trim() !== "") {
filtered = filtered.filter((agent) =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Apply agent type filter
if (selectedAgentType) {
filtered = filtered.filter(agent => agent.type === selectedAgentType);
}
setAgents(filtered);
}, [searchQuery, selectedAgentType, allAgents]);
useEffect(() => {
if (!clientId) return;
listApiKeys(clientId).then((res) => setApiKeys(res.data));
listMCPServers().then((res) => setAvailableMCPs(res.data));
}, [clientId]);
const handleDeleteEdge = useCallback(() => {
const id = edges.find((edge: any) => edge.source === selectedNode.id)?.id;
setEdges((edges: any) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
}, [nodes, edges, selectedNode, setEdges]);
const handleSelectAgent = (agent: Agent) => {
const updatedNode = {
...node,
data: {
...node.data,
agent,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleOpenAgentDialog = () => {
setNewAgent({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: { http_tools: [] },
sub_agents: [],
agent_tools: [],
},
folder_id: selectedFolderId || undefined,
});
setIsAgentDialogOpen(true);
};
const handleSaveAgent = async (agentData: Partial<Agent>) => {
setIsLoading(true);
try {
const sanitizedData = {
...agentData,
client_id: clientId,
name: agentData.name ? sanitizeAgentName(agentData.name) : agentData.name,
instruction: agentData.instruction ? escapePromptBraces(agentData.instruction) : agentData.instruction
};
if (isEditMode && node.data.agent?.id) {
// Update existing agent
const { updateAgent } = await import("@/services/agentService");
const updated = await updateAgent(node.data.agent.id, sanitizedData as any);
// Refresh the agent list
const res = await listAgents(clientId, 0, 100, selectedFolderId || undefined);
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
if (updated.data) {
handleSelectAgent(updated.data);
}
} else {
// Create new agent
const { createAgent } = await import("@/services/agentService");
const created = await createAgent(sanitizedData as any);
const res = await listAgents(clientId, 0, 100, selectedFolderId || undefined);
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
if (created.data) {
handleSelectAgent(created.data);
}
}
setIsAgentDialogOpen(false);
setIsEditMode(false);
} catch (e) {
console.error("Error saving agent:", e);
setIsAgentDialogOpen(false);
setIsEditMode(false);
} finally {
setIsLoading(false);
}
};
const handleFolderChange = (value: string) => {
setSelectedFolderId(value === "all" ? null : value);
};
const handleAgentTypeChange = (value: string) => {
setSelectedAgentType(value === "all" ? null : value);
};
const getFolderNameById = (id: string) => {
const folder = folders.find((f) => f.id === id);
return folder?.name || id;
};
const handleEditAgent = () => {
if (!node.data.agent) return;
setNewAgent({
...node.data.agent,
client_id: clientId || "",
});
setIsEditMode(true);
setIsAgentDialogOpen(true);
};
return (
<>
{isAgentDialogOpen && (
<GlobalAgentForm
open={isAgentDialogOpen}
onOpenChange={(open) => {
setIsAgentDialogOpen(open);
if (!open) setIsEditMode(false);
}}
initialValues={newAgent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={allAgents}
onOpenApiKeysDialog={() => {}}
onOpenMCPDialog={() => {}}
onOpenCustomMCPDialog={() => {}}
onSave={handleSaveAgent}
isLoading={isLoading}
getAgentNameById={(id) => allAgents.find((a) => a.id === id)?.name || id}
clientId={clientId}
/>
)}
{/* Agent Test Chat Modal - moved outside of nested divs to render properly */}
{isTestModalOpen && node.data.agent && (
<AgentTestChatModal
open={isTestModalOpen}
onOpenChange={setIsTestModalOpen}
agent={node.data.agent}
/>
)}
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search agents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus-visible:ring-emerald-500"
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex-1">
<Select
value={selectedFolderId ? selectedFolderId : "all"}
onValueChange={handleFolderChange}
>
<SelectTrigger className="w-full h-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus:ring-emerald-500 focus:ring-offset-0">
<SelectValue placeholder="All folders" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="all">All folders</SelectItem>
{folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select
value={selectedAgentType ? selectedAgentType : "all"}
onValueChange={handleAgentTypeChange}
>
<SelectTrigger className="w-full h-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus:ring-emerald-500 focus:ring-offset-0">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="all">All types</SelectItem>
{agentTypes.map((type) => (
<SelectItem key={type} value={type}>
{getAgentTypeName(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="px-4 pt-4 pb-2 flex items-center justify-between flex-shrink-0">
<h3 className="text-md font-medium text-neutral-200">
{searchQuery ? "Search Results" : "Select an Agent"}
</h3>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-emerald-800 hover:bg-emerald-700 border-emerald-700 text-emerald-100"
onClick={() => {
setNewAgent({
id: "",
name: "",
client_id: clientId || "",
type: "llm",
model: "",
config: {},
description: "",
});
setIsEditMode(false);
setIsAgentDialogOpen(true);
}}
aria-label="New Agent"
>
<Plus size={14} />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-4 scrollbar-hide">
<div className="space-y-2 pr-2">
{agents.length > 0 ? (
agents.map((agent) => (
<div
key={agent.id}
className={`p-3 rounded-md cursor-pointer transition-colors group relative ${
node.data.agent?.id === agent.id
? "bg-emerald-800/20 border border-emerald-600/40"
: "bg-neutral-800 hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => handleSelectAgent(agent)}
>
<div className="flex items-start gap-2">
<div className="bg-neutral-700 rounded-full p-1.5 flex-shrink-0">
<User size={18} className="text-neutral-300" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-medium text-neutral-200 truncate">{agent.name}</h3>
<div
className="ml-auto text-neutral-400 opacity-0 group-hover:opacity-100 hover:text-yellow-500 transition-colors p-1 rounded hover:bg-yellow-900/20"
onClick={(e) => {
e.stopPropagation();
setNewAgent({
...agent,
client_id: clientId || "",
});
setIsEditMode(true);
setIsAgentDialogOpen(true);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
</div>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-neutral-700 text-emerald-400 border-neutral-600"
>
{getAgentTypeName(agent.type)}
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400">{agent.model}</span>
)}
</div>
{agent.description && (
<p className="text-sm text-neutral-400 mt-1.5 line-clamp-2">
{agent.description.slice(0, 30)} {agent.description.length > 30 ? "..." : ""}
</p>
)}
</div>
</div>
</div>
))
) : (
<div className="text-center py-4 text-neutral-400">
No agents found
</div>
)}
</div>
</div>
</div>
{node.data.agent && (
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-md font-medium text-neutral-200">Selected Agent</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-neutral-700 hover:bg-neutral-600 border-neutral-600 text-neutral-200"
onClick={() => {
handleUpdateNode({
...node,
data: {
...node.data,
agent: null,
},
});
}}
aria-label="Clear agent"
>
<X size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-neutral-700 hover:bg-neutral-600 border-neutral-600 text-neutral-200"
onClick={handleEditAgent}
aria-label="Edit agent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-emerald-800 hover:bg-emerald-700 border-emerald-700 text-emerald-100"
onClick={() => setIsTestModalOpen(true)}
aria-label="Test agent"
>
<PlayIcon size={14} />
</Button>
</div>
</div>
<div className="p-3 rounded-md bg-emerald-800/20 border border-emerald-600/40">
<div className="flex items-start gap-2">
<div className="bg-emerald-900/50 rounded-full p-1.5 flex-shrink-0">
<User size={18} className="text-emerald-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200 truncate">{node.data.agent.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-emerald-900/50 text-emerald-400 border-emerald-700/50"
>
{getAgentTypeName(node.data.agent.type)}
</Badge>
{node.data.agent.model && (
<span className="text-xs text-neutral-400">{node.data.agent.model}</span>
)}
</div>
{node.data.agent.description && (
<p className="text-sm text-neutral-400 mt-1.5 line-clamp-2">
{node.data.agent.description.slice(0, 30)} {node.data.agent.description.length > 30 ? "..." : ""}
</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,176 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/agent/AgentNode.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { MessageCircle, User, Code, ExternalLink, Workflow, GitBranch, RefreshCw, BookOpenCheck, ArrowRight } from "lucide-react";
import { Agent } from "@/types/agent";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function AgentNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const agent = data.agent as Agent | undefined;
const isExecuting = data.isExecuting as boolean | undefined;
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const getAgentTypeIcon = (type: string) => {
switch (type) {
case "llm":
return <Code className="h-4 w-4 text-green-400" />;
case "a2a":
return <ExternalLink className="h-4 w-4 text-indigo-400" />;
case "sequential":
return <Workflow className="h-4 w-4 text-yellow-400" />;
case "parallel":
return <GitBranch className="h-4 w-4 text-purple-400" />;
case "loop":
return <RefreshCw className="h-4 w-4 text-orange-400" />;
case "workflow":
return <Workflow className="h-4 w-4 text-blue-400" />;
case "task":
return <BookOpenCheck className="h-4 w-4 text-red-400" />;
default:
return <User className="h-4 w-4 text-neutral-400" />;
}
};
const getModelBadgeColor = (model: string) => {
if (model?.includes('gpt-4')) return 'bg-green-900/30 text-green-400 border-green-600/30';
if (model?.includes('gpt-3')) return 'bg-yellow-900/30 text-yellow-400 border-yellow-600/30';
if (model?.includes('claude')) return 'bg-orange-900/30 text-orange-400 border-orange-600/30';
if (model?.includes('gemini')) return 'bg-blue-900/30 text-blue-400 border-blue-600/30';
return 'bg-neutral-800 text-neutral-400 border-neutral-600/50';
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="blue" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-900/40 shadow-sm">
<User className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-lg font-medium text-blue-400">
{data.label as string}
</p>
</div>
</div>
</div>
{agent ? (
<div className="mb-3 rounded-lg border border-blue-700/40 bg-blue-950/10 p-3 transition-all duration-200 hover:border-blue-600/50 hover:bg-blue-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
{getAgentTypeIcon(agent.type)}
<span className="ml-1.5 font-medium text-white">{agent.name}</span>
</div>
<Badge
variant="outline"
className="px-1.5 py-0 text-xs bg-blue-900/30 text-blue-400 border-blue-700/40"
>
{getAgentTypeName(agent.type)}
</Badge>
</div>
{agent.model && (
<div className="mt-2 flex items-center">
<Badge
variant="outline"
className={cn("px-1.5 py-0 text-xs", getModelBadgeColor(agent.model))}
>
{agent.model}
</Badge>
</div>
)}
{agent.description && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{agent.description.slice(0, 30)} {agent.description.length > 30 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-blue-700/40 bg-blue-950/10 p-5 text-center transition-all duration-200 hover:border-blue-600/50 hover:bg-blue-900/20">
<User className="h-8 w-8 text-blue-700/50 mb-2" />
<p className="text-blue-400">Select an agent</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-blue-500 !border-blue-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-blue-400 !border-blue-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,470 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/agent/AgentTestChatModal.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAgentWebSocket } from "@/hooks/use-agent-webSocket";
import { getAccessTokenFromCookie, cn } from "@/lib/utils";
import { Agent } from "@/types/agent";
import { ChatInput } from "@/app/chat/components/ChatInput";
import { ChatMessage as ChatMessageComponent } from "@/app/chat/components/ChatMessage";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChatPart } from "@/services/sessionService";
import { FileData } from "@/lib/file-utils";
import { X, User, Bot, Zap, MessageSquare, Loader2, Code, ExternalLink, Workflow, RefreshCw } from "lucide-react";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatMessage {
id: string;
content: any;
author: string;
timestamp: number;
}
interface AgentTestChatModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent;
canvasRef?: React.RefObject<any>;
}
export function AgentTestChatModal({ open, onOpenChange, agent, canvasRef }: AgentTestChatModalProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isSending, setIsSending] = useState(false);
const [expandedFunctions, setExpandedFunctions] = useState<Record<string, boolean>>({});
const [isInitializing, setIsInitializing] = useState(true);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || "{}") : {};
const clientId = user?.client_id || "test";
const generateExternalId = () => {
const now = new Date();
return (
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, "0") +
now.getDate().toString().padStart(2, "0") +
now.getHours().toString().padStart(2, "0") +
now.getMinutes().toString().padStart(2, "0") +
now.getSeconds().toString().padStart(2, "0") +
now.getMilliseconds().toString().padStart(3, "0")
);
};
const [externalId, setExternalId] = useState(generateExternalId());
const jwt = getAccessTokenFromCookie();
const onEvent = useCallback((event: any) => {
setMessages((prev) => [...prev, event]);
// Check if the message comes from a workflow node and highlight the node
// only if the canvasRef is available (called from Test Workflow on the main page)
if (event.author && event.author.startsWith('workflow-node:') && canvasRef?.current) {
const nodeId = event.author.split(':')[1];
canvasRef.current.setActiveExecutionNodeId(nodeId);
}
}, [canvasRef]);
const onTurnComplete = useCallback(() => {
setIsSending(false);
}, []);
const { sendMessage: wsSendMessage, disconnect } = useAgentWebSocket({
agentId: agent.id,
externalId,
jwt,
onEvent,
onTurnComplete,
});
// Handle ESC key to close the panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onOpenChange, open]);
// Show initialization state for better UX
useEffect(() => {
if (open) {
setIsInitializing(true);
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1200);
return () => clearTimeout(timer);
}
}, [open, externalId]);
const handleRestartChat = () => {
if (disconnect) disconnect();
setMessages([]);
setExpandedFunctions({});
setExternalId(generateExternalId());
setIsInitializing(true);
// Short delay to show the initialization status
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1200);
};
const handleSendMessageWithFiles = (message: string, files?: FileData[]) => {
if ((!message.trim() && (!files || files.length === 0))) return;
setIsSending(true);
const messageParts: ChatPart[] = [];
if (message.trim()) {
messageParts.push({ text: message });
}
if (files && files.length > 0) {
files.forEach(file => {
messageParts.push({
inline_data: {
data: file.data,
mime_type: file.content_type,
metadata: {
filename: file.filename
}
}
});
});
}
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: messageParts,
role: "user"
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(message, files);
};
const containsMarkdown = (text: string): boolean => {
if (!text || text.length < 3) return false;
const markdownPatterns = [
/[*_]{1,2}[^*_]+[*_]{1,2}/, // bold/italic
/\[[^\]]+\]\([^)]+\)/, // links
/^#{1,6}\s/m, // headers
/^[-*+]\s/m, // unordered lists
/^[0-9]+\.\s/m, // ordered lists
/^>\s/m, // block quotes
/`[^`]+`/, // inline code
/```[\s\S]*?```/, // code blocks
/^\|(.+\|)+$/m, // tables
/!\[[^\]]*\]\([^)]+\)/, // images
];
return markdownPatterns.some((pattern) => pattern.test(text));
};
const getMessageText = (message: ChatMessage): string | FunctionMessageContent => {
const author = message.author;
const parts = message.content.parts;
if (!parts || parts.length === 0) return "Empty content";
const functionCallPart = parts.find((part: any) => part.functionCall || part.function_call);
const functionResponsePart = parts.find((part: any) => part.functionResponse || part.function_response);
const inlineDataParts = parts.filter((part: any) => part.inline_data);
if (functionCallPart) {
const funcCall = functionCallPart.functionCall || functionCallPart.function_call || {};
const args = funcCall.args || {};
const name = funcCall.name || "unknown";
const id = funcCall.id || "no-id";
return {
author,
title: `📞 Function call: ${name}`,
content: `ID: ${id}\nArgs: ${Object.keys(args).length > 0 ? `\n${JSON.stringify(args, null, 2)}` : "{}"}`,
} as FunctionMessageContent;
}
if (functionResponsePart) {
const funcResponse = functionResponsePart.functionResponse || functionResponsePart.function_response || {};
const response = funcResponse.response || {};
const name = funcResponse.name || "unknown";
const id = funcResponse.id || "no-id";
const status = response.status || "unknown";
const statusEmoji = status === "error" ? "❌" : "✅";
let resultText = "";
if (status === "error") {
resultText = `Error: ${response.error_message || "Unknown error"}`;
} else if (response.report) {
resultText = `Result: ${response.report}`;
} else if (response.result && response.result.content) {
const content = response.result.content;
if (Array.isArray(content) && content.length > 0 && content[0].text) {
try {
const textContent = content[0].text;
const parsedJson = JSON.parse(textContent);
resultText = `Result: \n${JSON.stringify(parsedJson, null, 2)}`;
} catch (e) {
resultText = `Result: ${content[0].text}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
return {
author,
title: `${statusEmoji} Function response: ${name}`,
content: `ID: ${id}\n${resultText}`,
} as FunctionMessageContent;
}
if (parts.length === 1 && parts[0].text) {
return {
author,
content: parts[0].text,
title: "Message",
} as FunctionMessageContent;
}
const textParts = parts.filter((part: any) => part.text).map((part: any) => part.text).filter((text: string) => text);
if (textParts.length > 0) {
return {
author,
content: textParts.join("\n\n"),
title: "Message",
} as FunctionMessageContent;
}
try {
return JSON.stringify(parts, null, 2).replace(/\\n/g, "\n");
} catch (error) {
return "Unable to interpret message content";
}
};
const toggleFunctionExpansion = (messageId: string) => {
setExpandedFunctions((prev) => ({ ...prev, [messageId]: !prev[messageId] }));
};
const getAgentTypeIcon = (type: string) => {
switch (type) {
case "llm":
return <Code className="h-4 w-4 text-green-400" />;
case "a2a":
return <ExternalLink className="h-4 w-4 text-indigo-400" />;
case "sequential":
case "workflow":
return <Workflow className="h-4 w-4 text-blue-400" />;
default:
return <Bot className="h-4 w-4 text-emerald-400" />;
}
};
// Scroll to bottom whenever messages change
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
if (!open) return null;
// Use React Portal to render directly to document body, bypassing all parent containers
const modalContent = (
<>
{/* Overlay for mobile */}
<div
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-[15] transition-opacity duration-300"
onClick={() => onOpenChange(false)}
/>
{/* Side panel */}
<div
className="fixed right-0 top-0 z-[1000] h-full w-[450px] bg-gradient-to-b from-neutral-900 to-neutral-950 border-l border-neutral-800 shadow-2xl flex flex-col transition-all duration-300 ease-in-out transform"
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
boxShadow: '0 0 25px rgba(0, 0, 0, 0.3)',
}}
>
{/* Header */}
<div className="flex-shrink-0 p-5 bg-gradient-to-r from-neutral-900 to-neutral-800 border-b border-neutral-800">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center mb-1">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-900 flex items-center justify-center shadow-lg mr-3">
{getAgentTypeIcon(agent.type)}
</div>
<div>
<h2 className="text-lg font-semibold text-white">{agent.name}</h2>
<div className="flex items-center gap-2 mt-1">
<Badge
className="bg-emerald-900/40 text-emerald-400 border border-emerald-700/50 px-2"
>
{agent.type.toUpperCase()} Agent
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400 bg-neutral-800/60 px-2 py-0.5 rounded-md">
{agent.model}
</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleRestartChat}
className="p-1.5 rounded-full hover:bg-neutral-700/50 text-neutral-400 hover:text-white transition-colors group relative"
title="Restart chat"
disabled={isInitializing}
>
<RefreshCw size={18} className={isInitializing ? "animate-spin text-emerald-400" : ""} />
<span className="absolute -bottom-8 right-0 bg-neutral-800 text-neutral-200 text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap">
Restart chat
</span>
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded-full hover:bg-neutral-700/50 text-neutral-400 hover:text-white transition-colors group relative"
>
<X size={18} />
<span className="absolute -bottom-8 right-0 bg-neutral-800 text-neutral-200 text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap">
Close
</span>
</button>
</div>
</div>
{agent.description && (
<div className="mt-3 text-sm text-neutral-400 bg-neutral-800/30 p-3 rounded-md border border-neutral-800">
{agent.description}
</div>
)}
</div>
{/* Chat content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-3 bg-gradient-to-b from-neutral-900/50 to-neutral-950" ref={messagesContainerRef}>
{isInitializing ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 animate-pulse">
<Zap className="h-5 w-5 text-white" />
</div>
<p className="text-neutral-400 mb-2">Initializing connection...</p>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '0ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '150ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '300ms' }}></span>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500/20 to-emerald-500/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">Start the conversation</h3>
<p className="text-neutral-500 text-sm max-w-xs">
Type a message below to begin chatting with {agent.name}
</p>
</div>
) : (
<div className="space-y-4 w-full max-w-full">
{messages.map((message) => {
const messageContent = getMessageText(message);
const agentColor = message.author === "user" ? "bg-emerald-500" : "bg-gradient-to-br from-neutral-800 to-neutral-900";
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessageComponent
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
/>
);
})}
{isSending && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[80%]">
<Avatar
className="bg-gradient-to-br from-purple-600 to-purple-800 shadow-md border-0"
>
<AvatarFallback className="bg-transparent">
<Bot className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
<div className="rounded-lg p-3 bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50">
<div className="flex space-x-2">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.2s]"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Message input */}
<div className="p-3 border-t border-neutral-800 bg-neutral-900">
<ChatInput
onSendMessage={handleSendMessageWithFiles}
isLoading={isSending}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
</>
);
// Use createPortal to render the modal directly to the document body
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: null;
}

View File

@ -0,0 +1,68 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/agent/styles.css
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
.markdown-content {
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
.markdown-content pre {
white-space: pre-wrap;
overflow-x: auto;
max-width: 100%;
}
.markdown-content code {
white-space: pre-wrap;
word-break: break-all;
}
.markdown-content p {
max-width: 100%;
overflow-wrap: break-word;
}
.markdown-content table {
display: block;
overflow-x: auto;
max-width: 100%;
border-collapse: collapse;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,283 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/condition/ConditionDialog.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
import { Button } from "@/components/ui/button";
import { Filter, ArrowRight } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
const conditionTypes = [
{
id: "previous-output",
name: "Previous output",
description: "Validate the result returned by the previous node",
icon: <Filter className="h-5 w-5 text-blue-400" />,
color: "bg-blue-900/30 border-blue-700/50",
},
];
const operators = [
{ value: "is_defined", label: "is defined" },
{ value: "is_not_defined", label: "is not defined" },
{ value: "equals", label: "is equal to" },
{ value: "not_equals", label: "is not equal to" },
{ value: "contains", label: "contains" },
{ value: "not_contains", label: "does not contain" },
{ value: "starts_with", label: "starts with" },
{ value: "ends_with", label: "ends with" },
{ value: "greater_than", label: "is greater than" },
{ value: "greater_than_or_equal", label: "is greater than or equal to" },
{ value: "less_than", label: "is less than" },
{ value: "less_than_or_equal", label: "is less than or equal to" },
{ value: "matches", label: "matches the regex" },
{ value: "not_matches", label: "does not match the regex" },
];
const outputFields = [
{ value: "content", label: "Content" },
{ value: "status", label: "Status" },
];
function ConditionDialog({
open,
onOpenChange,
selectedNode,
handleUpdateNode,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedNode: any;
handleUpdateNode: any;
}) {
const [selectedType, setSelectedType] = useState("previous-output");
const [selectedField, setSelectedField] = useState(outputFields[0].value);
const [selectedOperator, setSelectedOperator] = useState(operators[0].value);
const [comparisonValue, setComparisonValue] = useState("");
const handleConditionSave = (condition: ConditionType) => {
const newConditions = selectedNode.data.conditions
? [...selectedNode.data.conditions]
: [];
newConditions.push(condition);
const updatedNode = {
...selectedNode,
data: {
...selectedNode.data,
conditions: newConditions,
},
};
handleUpdateNode(updatedNode);
onOpenChange(false);
};
const getOperatorLabel = (value: string) => {
return operators.find(op => op.value === value)?.label || value;
};
const getFieldLabel = (value: string) => {
return outputFields.find(field => field.value === value)?.label || value;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-neutral-800 border-neutral-700 text-neutral-200 sm:max-w-[650px]">
<DialogHeader>
<DialogTitle>Add New Condition</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="grid gap-4">
<Label className="text-sm font-medium">Condition Type</Label>
<div className="grid grid-cols-1 gap-2">
{conditionTypes.map((type) => (
<div
key={type.id}
className={`flex items-center space-x-3 rounded-md border p-3 cursor-pointer transition-all ${
selectedType === type.id
? "bg-blue-900/30 border-blue-600"
: "border-neutral-700 hover:border-blue-700/50 hover:bg-neutral-700/50"
}`}
onClick={() => setSelectedType(type.id)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-900/40">
{type.icon}
</div>
<div className="flex-1">
<h4 className="font-medium">{type.name}</h4>
<p className="text-xs text-neutral-400">{type.description}</p>
</div>
{selectedType === type.id && (
<Badge className="bg-blue-600 text-neutral-100">Selected</Badge>
)}
</div>
))}
</div>
</div>
<div className="grid gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Configuration</Label>
{selectedType === "previous-output" && (
<div className="flex items-center gap-2 text-sm text-neutral-400">
<span>Output field</span>
<ArrowRight className="h-3 w-3" />
<span>Operator</span>
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<>
<ArrowRight className="h-3 w-3" />
<span>Value</span>
</>
)}
</div>
)}
</div>
{selectedType === "previous-output" && (
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="field">Output Field</Label>
<Select
value={selectedField}
onValueChange={setSelectedField}
>
<SelectTrigger id="field" className="bg-neutral-700 border-neutral-600">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600">
{outputFields.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="operator">Operator</Label>
<Select
value={selectedOperator}
onValueChange={setSelectedOperator}
>
<SelectTrigger id="operator" className="bg-neutral-700 border-neutral-600">
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600">
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<div className="grid gap-2">
<Label htmlFor="value">Comparison Value</Label>
<Input
id="value"
value={comparisonValue}
onChange={(e) => setComparisonValue(e.target.value)}
className="bg-neutral-700 border-neutral-600"
/>
</div>
)}
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-4">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="text-sm">
<span className="text-blue-400 font-medium">{getFieldLabel(selectedField)}</span>
{" "}
<span className="text-neutral-300">{getOperatorLabel(selectedOperator)}</span>
{" "}
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<span className="text-emerald-400 font-medium">"{comparisonValue || "(empty)"}"</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-neutral-600 text-neutral-200 hover:bg-neutral-700"
>
Cancel
</Button>
<Button
onClick={() => {
handleConditionSave({
id: uuidv4(),
type: ConditionTypeEnum.PREVIOUS_OUTPUT,
data: {
field: selectedField,
operator: selectedOperator,
value: comparisonValue,
},
});
}}
className="bg-blue-700 hover:bg-blue-600 text-white"
>
Add Condition
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ConditionDialog };

View File

@ -0,0 +1,290 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/condition/ConditionForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
import { ConditionDialog } from "./ConditionDialog";
import { Button } from "@/components/ui/button";
import { Filter, Trash2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
/* eslint-disable @typescript-eslint/no-explicit-any */
function ConditionForm({
selectedNode,
handleUpdateNode,
}: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [conditions, setConditions] = useState<ConditionType[]>(
selectedNode.data.conditions || []
);
const [open, setOpen] = useState(false);
const [deleteDialog, setDeleteDialog] = useState(false);
const [conditionToDelete, setConditionToDelete] =
useState<ConditionType | null>(null);
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
setConditions(selectedNode.data.conditions || []);
}
}, [selectedNode]);
const handleDelete = (condition: ConditionType) => {
setConditionToDelete(condition);
setDeleteDialog(true);
};
const confirmDelete = () => {
if (!conditionToDelete) return;
const newConditions = conditions.filter(
(c) => c.id !== conditionToDelete.id
);
setConditions(newConditions);
handleUpdateNode({
...node,
data: { ...node.data, conditions: newConditions },
});
setDeleteDialog(false);
setConditionToDelete(null);
};
const renderCondition = (condition: ConditionType) => {
if (condition.type === ConditionTypeEnum.PREVIOUS_OUTPUT) {
type OperatorType =
| "is_defined"
| "is_not_defined"
| "equals"
| "not_equals"
| "contains"
| "not_contains"
| "starts_with"
| "ends_with"
| "greater_than"
| "greater_than_or_equal"
| "less_than"
| "less_than_or_equal"
| "matches"
| "not_matches";
const operatorText: Record<OperatorType, string> = {
is_defined: "is defined",
is_not_defined: "is not defined",
equals: "is equal to",
not_equals: "is not equal to",
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
greater_than: "is greater than",
greater_than_or_equal: "is greater than or equal to",
less_than: "is less than",
less_than_or_equal: "is less than or equal to",
matches: "matches the regex",
not_matches: "does not match the regex",
};
return (
<div
key={condition.id}
className="p-3 rounded-md cursor-pointer transition-colors bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 mb-2 group"
>
<div className="flex items-start gap-2">
<div className="bg-blue-900/50 rounded-full p-1.5 flex-shrink-0">
<Filter size={18} className="text-blue-300" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-medium text-neutral-200">Condition</h3>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(condition)}
className="h-7 w-7 text-neutral-400 opacity-0 group-hover:opacity-100 hover:text-red-500 hover:bg-red-900/20"
>
<Trash2 size={14} />
</Button>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-blue-900/20 text-blue-400 border-blue-700/50"
>
Field
</Badge>
<span className="text-sm text-neutral-300 font-medium">{condition.data.field}</span>
</div>
<div className="flex flex-wrap items-center gap-1 mt-1.5">
<span className="text-sm text-neutral-400">{operatorText[condition.data.operator as OperatorType]}</span>
{!["is_defined", "is_not_defined"].includes(condition.data.operator) && (
<span className="text-sm font-medium text-emerald-400">
"{condition.data.value}"
</span>
)}
</div>
</div>
</div>
</div>
);
}
return null;
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Logic Type</h3>
<Badge
variant="outline"
className="text-xs bg-blue-900/20 text-blue-400 border-blue-700/50"
>
{node.data.type === "or" ? "ANY" : "ALL"}
</Badge>
</div>
<Select
value={node.data.type || "and"}
onValueChange={(value) => {
const updatedNode = {
...node,
data: {
...node.data,
type: value,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
}}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="and">ALL (AND)</SelectItem>
<SelectItem value="or">ANY (OR)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-sm text-neutral-400 mt-2">
{node.data.type === "or"
? "Any of the following conditions must be true to proceed."
: "All of the following conditions must be true to proceed."}
</p>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-md font-medium text-neutral-200">Conditions</h3>
<Button
variant="outline"
size="sm"
className="h-8 bg-blue-800/20 hover:bg-blue-700/30 border-blue-700/50 text-blue-300"
onClick={() => setOpen(true)}
>
<Plus size={14} className="mr-1" />
Add Condition
</Button>
</div>
{conditions.length > 0 ? (
<div className="space-y-2">
{conditions.map((condition) => renderCondition(condition))}
</div>
) : (
<div
onClick={() => setOpen(true)}
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-neutral-700 hover:border-blue-600/50 hover:bg-neutral-800/50 transition-colors cursor-pointer text-center"
>
<Filter className="h-10 w-10 text-neutral-500 mb-2" />
<p className="text-neutral-400">No conditions yet</p>
<p className="text-sm text-neutral-500 mt-1">Click to add a condition</p>
</div>
)}
</div>
</div>
<ConditionDialog
open={open}
onOpenChange={setOpen}
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
/>
<Dialog open={deleteDialog} onOpenChange={setDeleteDialog}>
<DialogContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>Are you sure you want to delete this condition?</p>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-neutral-600 text-neutral-300 hover:bg-neutral-700"
onClick={() => {
setDeleteDialog(false);
setConditionToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
className="bg-red-900 hover:bg-red-800 text-white"
onClick={confirmDelete}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export { ConditionForm };

View File

@ -0,0 +1,204 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/condition/ConditionNode.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, Node, NodeProps, Position, useEdges } from "@xyflow/react";
import { FilterIcon, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
export type ConditionNodeType = Node<
{
label?: string;
type?: "and" | "or";
conditions?: ConditionType[];
},
"condition-node"
>;
export type OperatorType =
| "is_defined"
| "is_not_defined"
| "equals"
| "not_equals"
| "contains"
| "not_contains"
| "starts_with"
| "ends_with"
| "greater_than"
| "greater_than_or_equal"
| "less_than"
| "less_than_or_equal"
| "matches"
| "not_matches";
const operatorText: Record<OperatorType, string> = {
equals: "is equal to",
not_equals: "is not equal to",
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
greater_than: "is greater than",
greater_than_or_equal: "is greater than or equal to",
less_than: "is less than",
less_than_or_equal: "is less than or equal to",
matches: "matches the pattern",
not_matches: "does not match the pattern",
is_defined: "is defined",
is_not_defined: "is not defined",
};
export function ConditionNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const typeText = {
and: "all of the following conditions",
or: "any of the following conditions",
};
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId,
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const conditions: ConditionType[] = data.conditions as ConditionType[];
// const statistics: StatisticType = data.statistics as StatisticType;
const renderCondition = (condition: ConditionType) => {
const isConnected = isHandleConnected(condition.id);
if (condition.type === ConditionTypeEnum.PREVIOUS_OUTPUT) {
return (
<div
className="mb-3 cursor-pointer rounded-lg border border-purple-700/40 bg-purple-950/10 p-3 text-left transition-all duration-200 hover:border-purple-600/50 hover:bg-purple-900/10"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium text-neutral-300">
O campo{" "}
<span className="font-semibold text-purple-400">
{condition.data.field}
</span>{" "}
<span className="text-neutral-400">
{operatorText[condition.data.operator as OperatorType]}
</span>{" "}
{!["is_defined", "is_not_defined"].includes(
condition.data.operator,
) && (
<span className="font-semibold text-green-400">
&quot;{condition.data.value}&quot;
</span>
)}
</p>
</div>
<Handle
className={cn(
"!rounded-full transition-all duration-300",
isConnected ? "!bg-purple-500 !border-purple-400" : "!bg-neutral-400 !border-neutral-500"
)}
style={{
top: "50%",
right: "-5px",
transform: "translateY(-50%)",
height: "14px",
position: "relative",
width: "14px",
}}
type="source"
position={Position.Right}
id={condition.id}
/>
</div>
</div>
);
}
return null;
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="purple" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-900/40 shadow-sm">
<FilterIcon className="h-5 w-5 text-purple-400" />
</div>
<div>
<p className="text-lg font-medium text-purple-400">
{data.label as string}
</p>
<p className="text-xs text-neutral-400">
Matches {typeText[(data.type as "and" | "or") || "and"]}
</p>
</div>
</div>
</div>
{conditions && conditions.length > 0 && Array.isArray(conditions) ? (
conditions.map((condition) => (
<div key={condition.id}>{renderCondition(condition)}</div>
))
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-purple-700/40 bg-purple-950/10 p-5 text-center transition-all duration-200 hover:border-purple-600/50 hover:bg-purple-900/20">
<FilterIcon className="h-8 w-8 text-purple-700/50 mb-2" />
<p className="text-purple-400">No conditions configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to add a condition</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-purple-500 !border-purple-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-purple-400 !border-purple-300"
)}
style={{
right: "0px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,256 @@
/*
@author: Davidson Gomes
@author: Victor Calazans - Implementation of Delay node form
@file: /app/agents/workflows/nodes/components/delay/DelayForm.tsx
Developed by: Davidson Gomes
Delay form developed by: Victor Calazans
Creation date: May 13, 2025
Delay form implementation date: May 17, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect } from "react";
import { Dispatch, SetStateAction } from "react";
import { Clock, Trash2, Save, AlertCircle, HourglassIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DelayType, DelayUnitEnum } from "../../nodeFunctions";
import { cn } from "@/lib/utils";
export function DelayForm({
selectedNode,
handleUpdateNode,
setEdges,
setIsOpen,
setSelectedNode,
}: {
selectedNode: any;
handleUpdateNode: (node: any) => void;
setEdges: any;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setSelectedNode: Dispatch<SetStateAction<any>>;
}) {
const [delay, setDelay] = useState<DelayType>({
value: 1,
unit: DelayUnitEnum.SECONDS,
description: "",
});
useEffect(() => {
if (selectedNode?.data?.delay) {
setDelay(selectedNode.data.delay);
}
}, [selectedNode]);
const handleSave = () => {
handleUpdateNode({
...selectedNode,
data: {
...selectedNode.data,
delay,
},
});
};
const handleDelete = () => {
setEdges((edges: any) => {
return edges.filter(
(edge: any) => edge.source !== selectedNode.id && edge.target !== selectedNode.id
);
});
setIsOpen(false);
setSelectedNode(null);
};
const getUnitLabel = (unit: DelayUnitEnum) => {
const units = {
[DelayUnitEnum.SECONDS]: "Seconds",
[DelayUnitEnum.MINUTES]: "Minutes",
[DelayUnitEnum.HOURS]: "Hours",
[DelayUnitEnum.DAYS]: "Days",
};
return units[unit] || unit;
};
const getTimeDescription = () => {
const value = delay.value || 0;
if (value <= 0) return "Invalid time";
if (value === 1) {
return `1 ${getUnitLabel(delay.unit).slice(0, -1)}`;
}
return `${value} ${getUnitLabel(delay.unit)}`;
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Delay Duration</h3>
<Badge
variant="outline"
className="text-xs bg-yellow-900/20 text-yellow-400 border-yellow-700/50"
>
{getTimeDescription().toUpperCase()}
</Badge>
</div>
<Select
value={delay.unit}
onValueChange={(value) =>
setDelay({
...delay,
unit: value as DelayUnitEnum,
})
}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value={DelayUnitEnum.SECONDS}>Seconds</SelectItem>
<SelectItem value={DelayUnitEnum.MINUTES}>Minutes</SelectItem>
<SelectItem value={DelayUnitEnum.HOURS}>Hours</SelectItem>
<SelectItem value={DelayUnitEnum.DAYS}>Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<div className="grid gap-4">
<div className="p-3 rounded-md bg-yellow-900/10 border border-yellow-700/30 mb-2">
<div className="flex items-start gap-2">
<div className="bg-yellow-900/50 rounded-full p-1.5 flex-shrink-0">
<Clock size={18} className="text-yellow-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200">Time Delay</h3>
<p className="text-sm text-neutral-400 mt-1">
Pause workflow execution for a specified amount of time
</p>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="delay-value">Delay Value</Label>
<Input
id="delay-value"
type="number"
min="1"
className="bg-neutral-700 border-neutral-600"
value={delay.value}
onChange={(e) =>
setDelay({
...delay,
value: parseInt(e.target.value) || 1,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="delay-description">Description (optional)</Label>
<Textarea
id="delay-description"
className="bg-neutral-700 border-neutral-600 min-h-[100px] resize-none"
value={delay.description}
onChange={(e) =>
setDelay({
...delay,
description: e.target.value,
})
}
placeholder="Add a description for this delay"
/>
</div>
{delay.value > 0 ? (
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-2">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-neutral-800/70">
<div className="rounded-full bg-yellow-900/30 p-1.5 mt-0.5">
<HourglassIcon size={15} className="text-yellow-400" />
</div>
<div className="flex-1">
<div className="flex flex-col">
<span className="text-sm text-yellow-400 font-medium">
{getTimeDescription()} delay
</span>
{delay.description && (
<span className="text-xs text-neutral-400 mt-1">
{delay.description}
</span>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-md bg-neutral-700/30 border border-neutral-600/50 p-4 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-6 w-6 text-neutral-500 mb-2" />
<p className="text-neutral-400 text-sm">Please set a valid delay time</p>
</div>
)}
</div>
</div>
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<div className="flex gap-2 justify-between">
<Button
variant="outline"
className="border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/30 hover:text-red-300"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Node
</Button>
<Button
className="bg-yellow-700 hover:bg-yellow-600 text-white flex items-center gap-2"
onClick={handleSave}
>
<Save size={16} />
Save Delay
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
/*
@author: Davidson Gomes
@author: Victor Calazans - Implementation of Delay node functionality
@file: /app/agents/workflows/nodes/components/delay/DelayNode.tsx
Developed by: Davidson Gomes
Delay node developed by: Victor Calazans
Creation date: May 13, 2025
Delay node implementation date: May 17, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { Clock, ArrowRight, Timer } from "lucide-react";
import { DelayType } from "../../nodeFunctions";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function DelayNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const delay = data.delay as DelayType | undefined;
const getUnitLabel = (unit: string) => {
switch (unit) {
case 'seconds':
return 'Seconds';
case 'minutes':
return 'Minutes';
case 'hours':
return 'Hours';
case 'days':
return 'Days';
default:
return unit;
}
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="yellow" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-900/40 shadow-sm">
<Clock className="h-5 w-5 text-yellow-400" />
</div>
<div>
<p className="text-lg font-medium text-yellow-400">
{data.label as string}
</p>
</div>
</div>
</div>
{delay ? (
<div className="mb-3 rounded-lg border border-yellow-700/40 bg-yellow-950/10 p-3 transition-all duration-200 hover:border-yellow-600/50 hover:bg-yellow-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
<Timer className="h-4 w-4 text-yellow-400" />
<span className="ml-1.5 font-medium text-white">Delay</span>
</div>
<Badge
variant="outline"
className="px-1.5 py-0 text-xs bg-yellow-900/30 text-yellow-400 border-yellow-700/40"
>
{getUnitLabel(delay.unit)}
</Badge>
</div>
<div className="mt-2 flex items-center">
<span className="text-lg font-semibold text-yellow-300">{delay.value}</span>
<span className="ml-1 text-sm text-neutral-400">{delay.unit}</span>
</div>
{delay.description && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{delay.description.slice(0, 80)} {delay.description.length > 80 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-yellow-700/40 bg-yellow-950/10 p-5 text-center transition-all duration-200 hover:border-yellow-600/50 hover:bg-yellow-900/20">
<Clock className="h-8 w-8 text-yellow-700/50 mb-2" />
<p className="text-yellow-400">No delay configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-yellow-500 !border-yellow-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-yellow-400 !border-yellow-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,292 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/message/MessageForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable jsx-a11y/alt-text */
import { useEdges, useNodes } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Agent } from "@/types/agent";
import { listAgents } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
MessageSquare,
Save,
Text,
Image,
File,
Video,
AlertCircle
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
function MessageForm({
selectedNode,
handleUpdateNode,
setEdges,
setIsOpen,
setSelectedNode,
}: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [messageType, setMessageType] = useState("text");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [allAgents, setAllAgents] = useState<Agent[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const edges = useEdges();
const nodes = useNodes();
const connectedNode = useMemo(() => {
const edge = edges.find((edge) => edge.source === selectedNode.id);
if (!edge) return null;
const node = nodes.find((node) => node.id === edge.target);
return node || null;
}, [edges, nodes, selectedNode.id]);
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || '{}') : {};
const clientId = user?.client_id || "";
const currentAgent = typeof window !== "undefined" ?
JSON.parse(localStorage.getItem("current_workflow_agent") || '{}') : {};
const currentAgentId = currentAgent?.id;
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
setMessageType(selectedNode.data.message?.type || "text");
setContent(selectedNode.data.message?.content || "");
}
}, [selectedNode]);
useEffect(() => {
if (!clientId) return;
setLoading(true);
listAgents(clientId)
.then((res) => {
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
})
.catch((error) => console.error("Error loading agents:", error))
.finally(() => setLoading(false));
}, [clientId, currentAgentId]);
useEffect(() => {
if (searchTerm.trim() === "") {
setAgents(allAgents);
} else {
const filtered = allAgents.filter((agent) =>
agent.name.toLowerCase().includes(searchTerm.toLowerCase())
);
setAgents(filtered);
}
}, [searchTerm, allAgents]);
const handleDeleteEdge = useCallback(() => {
const id = edges.find((edge: any) => edge.source === selectedNode.id)?.id;
setEdges((edges: any) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
}, [nodes, edges, selectedNode, setEdges]);
const handleSelectAgent = (agent: Agent) => {
const updatedNode = {
...node,
data: {
...node.data,
agent,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleSave = () => {
const updatedNode = {
...node,
data: {
...node.data,
message: {
type: messageType,
content,
},
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const messageTypeInfo = {
text: {
icon: <Text className="h-5 w-5 text-orange-400" />,
name: "Text Message",
description: "Simple text message",
color: "bg-orange-900/30 border-orange-700/50",
},
image: {
icon: <Image className="h-5 w-5 text-blue-400" />,
name: "Image",
description: "Image URL or base64",
color: "bg-blue-900/30 border-blue-700/50",
},
file: {
icon: <File className="h-5 w-5 text-emerald-400" />,
name: "File",
description: "File URL or base64",
color: "bg-emerald-900/30 border-emerald-700/50",
},
video: {
icon: <Video className="h-5 w-5 text-purple-400" />,
name: "Video",
description: "Video URL or base64",
color: "bg-purple-900/30 border-purple-700/50",
},
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Message Type</h3>
<Badge
variant="outline"
className="text-xs bg-orange-900/20 text-orange-400 border-orange-700/50"
>
{messageType === "text" ? "TEXT" : messageType.toUpperCase()}
</Badge>
</div>
<Select
value={messageType}
onValueChange={setMessageType}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="text">Text</SelectItem>
{/* Other options can be enabled in the future */}
{/* <SelectItem value="image">Image</SelectItem>
<SelectItem value="file">File</SelectItem>
<SelectItem value="video">Video</SelectItem> */}
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<div className="grid gap-4">
<div className="p-3 rounded-md bg-orange-900/10 border border-orange-700/30 mb-2">
<div className="flex items-start gap-2">
<div className="bg-orange-900/50 rounded-full p-1.5 flex-shrink-0">
<MessageSquare size={18} className="text-orange-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200">{messageTypeInfo.text.name}</h3>
<p className="text-sm text-neutral-400 mt-1">{messageTypeInfo.text.description}</p>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Message Content</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type your message here..."
className="min-h-[150px] bg-neutral-700 border-neutral-600 resize-none"
/>
</div>
{content.trim() !== "" ? (
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-2">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-neutral-800/70">
<div className="rounded-full bg-orange-900/30 p-1.5 mt-0.5">
<MessageSquare size={15} className="text-orange-400" />
</div>
<div className="text-sm text-neutral-300 whitespace-pre-wrap">{content}</div>
</div>
</div>
) : (
<div className="rounded-md bg-neutral-700/30 border border-neutral-600/50 p-4 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-6 w-6 text-neutral-500 mb-2" />
<p className="text-neutral-400 text-sm">Your message will appear here</p>
</div>
)}
</div>
</div>
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<Button
className="w-full bg-orange-700 hover:bg-orange-600 text-white flex items-center gap-2 justify-center"
onClick={handleSave}
>
<Save size={16} />
Save Message
</Button>
</div>
</div>
);
}
export { MessageForm };

View File

@ -0,0 +1,162 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/message/MessageNode.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { MessageCircle, Text, Image, File, Video, ArrowRight } from "lucide-react";
import { MessageType, MessageTypeEnum } from "../../nodeFunctions";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function MessageNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const message = data.message as MessageType | undefined;
const getMessageTypeIcon = (type: string) => {
switch (type) {
case MessageTypeEnum.TEXT:
return <Text className="h-4 w-4 text-orange-400" />;
case "image":
return <Image className="h-4 w-4 text-blue-400" />;
case "file":
return <File className="h-4 w-4 text-emerald-400" />;
case "video":
return <Video className="h-4 w-4 text-purple-400" />;
default:
return <MessageCircle className="h-4 w-4 text-orange-400" />;
}
};
const getMessageTypeColor = (type: string) => {
switch (type) {
case MessageTypeEnum.TEXT:
return 'bg-orange-900/30 text-orange-400 border-orange-700/40';
case "image":
return 'bg-blue-900/30 text-blue-400 border-blue-700/40';
case "file":
return 'bg-emerald-900/30 text-emerald-400 border-emerald-700/40';
case "video":
return 'bg-purple-900/30 text-purple-400 border-purple-700/40';
default:
return 'bg-orange-900/30 text-orange-400 border-orange-700/40';
}
};
const getMessageTypeName = (type: string) => {
const messageTypes: Record<string, string> = {
text: "Text Message",
image: "Image",
file: "File",
video: "Video",
};
return messageTypes[type] || type;
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="orange" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-900/40 shadow-sm">
<MessageCircle className="h-5 w-5 text-orange-400" />
</div>
<div>
<p className="text-lg font-medium text-orange-400">
{data.label as string}
</p>
</div>
</div>
</div>
{message ? (
<div className="mb-3 rounded-lg border border-orange-700/40 bg-orange-950/10 p-3 transition-all duration-200 hover:border-orange-600/50 hover:bg-orange-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
{getMessageTypeIcon(message.type)}
<span className="ml-1.5 font-medium text-white">{getMessageTypeName(message.type)}</span>
</div>
<Badge
variant="outline"
className={cn("px-1.5 py-0 text-xs", getMessageTypeColor(message.type))}
>
{message.type}
</Badge>
</div>
{message.content && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{message.content.slice(0, 80)} {message.content.length > 80 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-orange-700/40 bg-orange-950/10 p-5 text-center transition-all duration-200 hover:border-orange-600/50 hover:bg-orange-900/20">
<MessageCircle className="h-8 w-8 text-orange-700/50 mb-2" />
<p className="text-orange-400">No message configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-orange-500 !border-orange-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-orange-400 !border-orange-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,100 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/components/start/StartNode.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, Node, NodeProps, Position, useEdges } from "@xyflow/react";
import { Zap, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export type StartNodeType = Node<
{
label?: string;
},
"start-node"
>;
export function StartNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isSourceHandleConnected = edges.some(
(edge) => edge.source === props.id
);
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="emerald" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/40 shadow-sm">
<Zap className="h-5 w-5 text-emerald-400" />
</div>
<div>
<p className="text-lg font-medium text-emerald-400">Start</p>
</div>
</div>
</div>
<div className="mb-3 rounded-lg border border-emerald-700/40 bg-emerald-950/10 p-3 transition-all duration-200 hover:border-emerald-600/50 hover:bg-emerald-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
<span className="font-medium text-white">Input: User content</span>
</div>
</div>
<p className="mt-2 text-xs text-neutral-400">
The workflow begins when a user sends a message to the agent
</p>
</div>
</div>
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isSourceHandleConnected ? "!bg-emerald-500 !border-emerald-400" : "!bg-neutral-400 !border-neutral-500",
selected && isSourceHandleConnected && "!bg-emerald-400 !border-emerald-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
/>
</div>
</BaseNode>
);
}

View File

@ -0,0 +1,109 @@
/*
@author: Davidson Gomes
@author: Victor Calazans - Implementation of Delay node type
@file: /app/agents/workflows/nodes/index.ts
Developed by: Davidson Gomes
Delay node functionality developed by: Victor Calazans
Creation date: May 13, 2025
Delay implementation date: May 17, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import type { NodeTypes, BuiltInNode, Node } from "@xyflow/react";
import { ConditionNode } from "./components/condition/ConditionNode";
import { AgentNode } from "./components/agent/AgentNode";
import { StartNode, StartNodeType } from "./components/start/StartNode";
import { MessageNode } from "./components/message/MessageNode";
import { DelayNode } from "./components/delay/DelayNode";
import "./style.css";
import {
ConditionType,
MessageType,
DelayType,
} from "./nodeFunctions";
import { Agent } from "@/types/agent";
type AgentNodeType = Node<
{
label?: string;
agent?: Agent;
},
"agent-node"
>;
type MessageNodeType = Node<
{
label?: string;
message?: MessageType;
},
"message-node"
>;
type DelayNodeType = Node<
{
label?: string;
delay?: DelayType;
},
"delay-node"
>;
type ConditionNodeType = Node<
{
label?: string;
integration?: string;
icon?: string;
conditions?: ConditionType[];
},
"condition-node"
>;
export type AppNode =
| BuiltInNode
| StartNodeType
| AgentNodeType
| ConditionNodeType
| MessageNodeType
| DelayNodeType;
export type NodeType = AppNode["type"];
export const initialNodes: AppNode[] = [
{
id: "start-node",
type: "start-node",
position: { x: -100, y: 100 },
data: {
label: "Start",
},
},
];
export const nodeTypes = {
"start-node": StartNode,
"agent-node": AgentNode,
"message-node": MessageNode,
"condition-node": ConditionNode,
"delay-node": DelayNode,
} satisfies NodeTypes;

View File

@ -0,0 +1,65 @@
/*
@author: Davidson Gomes
@author: Victor Calazans - Implementation of Delay types
@file: /app/agents/workflows/nodes/nodeFunctions.ts
Developed by: Davidson Gomes
Delay node functionality developed by: Victor Calazans
Creation date: May 13, 2025
Delay implementation date: May 17, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-unused-vars */
export enum ConditionTypeEnum {
PREVIOUS_OUTPUT = "previous-output",
}
export enum MessageTypeEnum {
TEXT = "text",
}
export enum DelayUnitEnum {
SECONDS = "seconds",
MINUTES = "minutes",
HOURS = "hours",
DAYS = "days",
}
export type MessageType = {
type: MessageTypeEnum;
content: string;
};
export type DelayType = {
value: number;
unit: DelayUnitEnum;
description?: string;
};
export type ConditionType = {
id: string;
type: ConditionTypeEnum;
data?: any;
};

View File

@ -0,0 +1,54 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/nodes/style.css
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
/* .react-flow__handle {
background-color: #8492A6;
border-radius: 50%;
height: 16px;
position: absolute;
width: 16px;
} */
/* .react-flow__handle-right {
right: -6px;
top: 88%;
transform: translateY(-75%);
background-color: #f5f5f5 !important;
border: 3px solid #8492A6 !important;
} */
/* .react-flow__handle-left {
left: 0px;
top: 40%;
width: 60px;
border-radius: 0;
height: 50%;
transform: translateY(-75%);
background-color: transparent;
border: none;
} */

View File

@ -0,0 +1,218 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useEffect, useState, useRef, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import Canva from "./Canva";
import { Agent } from "@/types/agent";
import { getAgent, updateAgent } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Download, PlayIcon } from "lucide-react";
import Link from "next/link";
import { ReactFlowProvider } from "@xyflow/react";
import { DnDProvider } from "@/contexts/DnDContext";
import { NodeDataProvider } from "@/contexts/NodeDataContext";
import { SourceClickProvider } from "@/contexts/SourceClickContext";
import { useToast } from "@/hooks/use-toast";
import { AgentTestChatModal } from "./nodes/components/agent/AgentTestChatModal";
function WorkflowsContent() {
const searchParams = useSearchParams();
const agentId = searchParams.get("agentId");
const [agent, setAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(false);
const canvaRef = useRef<any>(null);
const { toast } = useToast();
const [isTestModalOpen, setIsTestModalOpen] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
useEffect(() => {
if (agentId && clientId) {
setLoading(true);
getAgent(agentId, clientId)
.then((res) => {
setAgent(res.data);
if (typeof window !== "undefined") {
localStorage.setItem(
"current_workflow_agent",
JSON.stringify(res.data)
);
}
})
.catch((err) => {
console.error("Error loading agent:", err);
})
.finally(() => {
setLoading(false);
});
}
}, [agentId, clientId]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
localStorage.removeItem("current_workflow_agent");
}
};
}, []);
const handleSaveWorkflow = async () => {
if (!agent || !canvaRef.current) return;
try {
const { nodes, edges } = canvaRef.current.getFlowData();
const workflow = {
nodes,
edges,
};
await updateAgent(agent.id, {
...agent,
config: {
...agent.config,
workflow,
},
});
toast({
title: "Workflow saved",
description: "The changes were saved successfully",
});
canvaRef.current.setHasChanges(false);
} catch (error) {
console.error("Error saving workflow:", error);
toast({
title: "Error saving workflow",
description: "Unable to save the changes",
variant: "destructive",
});
}
};
if (loading) {
return (
<div className="w-full h-screen bg-[#121212] flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
);
}
return (
<div className="relative w-full h-screen flex flex-col" data-workflow-page="true">
{/* Header with controls */}
<div className="w-full bg-[#121212] py-4 px-6 z-10 flex items-center justify-between border-b border-neutral-800">
<div className="flex items-center gap-2">
<Link href="/agents">
<Button
variant="outline"
className="bg-neutral-800 border-neutral-700 text-neutral-200 hover:bg-neutral-700"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Agents
</Button>
</Link>
{agent && (
<div className="bg-neutral-800 px-4 py-2 rounded-md">
<h2 className="text-neutral-200 font-medium">
{agent.name} -{" "}
<span className="text-neutral-400 text-sm">{agent.type}</span>
</h2>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="bg-neutral-800 border-neutral-700 text-neutral-200 hover:bg-neutral-700"
onClick={handleSaveWorkflow}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{agent && (
<Button
variant="outline"
className="bg-green-800 border-green-700 text-green-200 hover:bg-green-700"
onClick={() => setIsTestModalOpen(true)}
>
<PlayIcon className="h-4 w-4 mr-2" />
Test Workflow
</Button>
)}
</div>
</div>
{/* Main content area */}
<div className="flex-1 relative overflow-hidden">
{agent && isTestModalOpen && (
<AgentTestChatModal
open={isTestModalOpen}
onOpenChange={setIsTestModalOpen}
agent={agent}
canvasRef={canvaRef} // Pass the canvas reference to allow visualization of running nodes
/>
)}
<NodeDataProvider>
<SourceClickProvider>
<DnDProvider>
<ReactFlowProvider>
<Canva agent={agent} ref={canvaRef} data-canvas-ref="true" />
</ReactFlowProvider>
</DnDProvider>
</SourceClickProvider>
</NodeDataProvider>
</div>
</div>
);
}
export default function WorkflowsPage() {
return (
<Suspense
fallback={
<div className="w-full h-screen bg-[#121212] flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}
>
<WorkflowsContent />
</Suspense>
);
}

View File

@ -0,0 +1,199 @@
/*
@author: Davidson Gomes
@file: /app/agents/workflows/utils.ts
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Node, NodePositionChange, XYPosition } from "@xyflow/react";
type GetHelperLinesResult = {
horizontal?: number;
vertical?: number;
snapPosition: Partial<XYPosition>;
};
// this utility function can be called with a position change (inside onNodesChange)
// it checks all other nodes and calculated the helper line positions and the position where the current node should snap to
export function getHelperLines(
change: NodePositionChange,
nodes: Node[],
distance = 5,
): GetHelperLinesResult {
const defaultResult = {
horizontal: undefined,
vertical: undefined,
snapPosition: { x: undefined, y: undefined },
};
const nodeA = nodes.find((node) => node.id === change.id);
if (!nodeA || !change.position) {
return defaultResult;
}
const nodeABounds = {
left: change.position.x,
right: change.position.x + (nodeA.measured?.width ?? 0),
top: change.position.y,
bottom: change.position.y + (nodeA.measured?.height ?? 0),
width: nodeA.measured?.width ?? 0,
height: nodeA.measured?.height ?? 0,
};
let horizontalDistance = distance;
let verticalDistance = distance;
return nodes
.filter((node) => node.id !== nodeA.id)
.reduce<GetHelperLinesResult>((result, nodeB) => {
const nodeBBounds = {
left: nodeB.position.x,
right: nodeB.position.x + (nodeB.measured?.width ?? 0),
top: nodeB.position.y,
bottom: nodeB.position.y + (nodeB.measured?.height ?? 0),
width: nodeB.measured?.width ?? 0,
height: nodeB.measured?.height ?? 0,
};
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left);
if (distanceLeftLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left;
result.vertical = nodeBBounds.left;
verticalDistance = distanceLeftLeft;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightRight = Math.abs(
nodeABounds.right - nodeBBounds.right,
);
if (distanceRightRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right - nodeABounds.width;
result.vertical = nodeBBounds.right;
verticalDistance = distanceRightRight;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right);
if (distanceLeftRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right;
result.vertical = nodeBBounds.right;
verticalDistance = distanceLeftRight;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left);
if (distanceRightLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left - nodeABounds.width;
result.vertical = nodeBBounds.left;
verticalDistance = distanceRightLeft;
}
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________| |___________|
const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top);
if (distanceTopTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top;
result.horizontal = nodeBBounds.top;
horizontalDistance = distanceTopTop;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|_________________
// | |
// | B |
// |___________|
const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top);
if (distanceBottomTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top - nodeABounds.height;
result.horizontal = nodeBBounds.top;
horizontalDistance = distanceBottomTop;
}
// |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________|_____|___________|
const distanceBottomBottom = Math.abs(
nodeABounds.bottom - nodeBBounds.bottom,
);
if (distanceBottomBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height;
result.horizontal = nodeBBounds.bottom;
horizontalDistance = distanceBottomBottom;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// | |
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
// | A |
// |___________|
const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom);
if (distanceTopBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom;
result.horizontal = nodeBBounds.bottom;
horizontalDistance = distanceTopBottom;
}
return result;
}, defaultResult);
}

View File

@ -0,0 +1,497 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/AgentInfoDialog.tsx
Developed by: Davidson Gomes
Creation date: May 14, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useEffect } from "react";
import { Agent } from "@/types/agent";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Bot,
Code,
WrenchIcon,
Layers,
Server,
TagIcon,
Share,
Edit,
Loader2,
Download,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { listApiKeys, ApiKey } from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { availableModels } from "@/types/aiModels";
import { MCPServer } from "@/types/mcpServer";
import { AgentForm } from "@/app/agents/forms/AgentForm";
import { exportAsJson } from "@/lib/utils";
interface AgentInfoDialogProps {
agent: Agent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentUpdated?: (updatedAgent: Agent) => void;
}
export function AgentInfoDialog({
agent,
open,
onOpenChange,
onAgentUpdated,
}: AgentInfoDialogProps) {
const [activeTab, setActiveTab] = useState("info");
const [isAgentFormOpen, setIsAgentFormOpen] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
useEffect(() => {
if (!clientId || !open) return;
const loadData = async () => {
try {
const [apiKeysResponse, mcpServersResponse] = await Promise.all([
listApiKeys(clientId),
listMCPServers(),
]);
setApiKeys(apiKeysResponse.data);
setAvailableMCPs(mcpServersResponse.data);
} catch (error) {
console.error("Error loading data for agent form:", error);
}
};
loadData();
}, [clientId, open]);
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleSaveAgent = async (agentData: Partial<Agent>) => {
if (!agent?.id) return;
setIsLoading(true);
try {
const { updateAgent } = await import("@/services/agentService");
const updated = await updateAgent(agent.id, agentData as any);
if (updated.data && onAgentUpdated) {
onAgentUpdated(updated.data);
}
setIsAgentFormOpen(false);
} catch (error) {
console.error("Error updating agent:", error);
} finally {
setIsLoading(false);
}
};
// Function to export the agent as JSON
const handleExportAgent = async () => {
if (!agent) return;
try {
// First fetch all agents to properly resolve agent_tools references
const { listAgents } = await import("@/services/agentService");
const allAgentsResponse = await listAgents(clientId, 0, 1000);
const allAgents = allAgentsResponse.data || [];
exportAsJson(
agent,
`agent-${agent.name.replace(/\s+/g, "-").toLowerCase()}-${agent.id.substring(0, 8)}`,
true,
allAgents
);
} catch (error) {
console.error("Error exporting agent:", error);
}
};
if (!agent) return null;
const getToolsCount = () => {
let count = 0;
if (agent.config?.tools) count += agent.config.tools.length;
if (agent.config?.custom_tools?.http_tools)
count += agent.config.custom_tools.http_tools.length;
if (agent.config?.agent_tools)
count += agent.config.agent_tools.length;
return count;
};
const getSubAgentsCount = () => {
return agent.config?.sub_agents?.length || 0;
};
const getMCPServersCount = () => {
let count = 0;
if (agent.config?.mcp_servers) count += agent.config.mcp_servers.length;
if (agent.config?.custom_mcp_servers)
count += agent.config.custom_mcp_servers.length;
return count;
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col overflow-hidden bg-neutral-900 border-neutral-700">
<DialogHeader className="flex flex-row items-start justify-between pb-2">
<div>
<DialogTitle className="text-xl text-white flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-400" />
{agent.name}
</DialogTitle>
<DialogDescription className="text-neutral-400 mt-1">
{agent.description || "No description available"}
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-neutral-800 border-neutral-700 text-emerald-400"
>
{getAgentTypeName(agent.type)}
</Badge>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-full bg-neutral-800 border-neutral-700 hover:bg-emerald-900 hover:text-emerald-400"
onClick={handleExportAgent}
title="Export agent as JSON"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-full bg-neutral-800 border-neutral-700 hover:bg-emerald-900 hover:text-emerald-400"
onClick={() => setIsAgentFormOpen(true)}
title="Edit agent"
>
<Edit className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 overflow-hidden flex flex-col"
>
<TabsList className="bg-neutral-800 p-1 border-b border-neutral-700">
<TabsTrigger
value="info"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Information
</TabsTrigger>
<TabsTrigger
value="tools"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Tools
</TabsTrigger>
<TabsTrigger
value="config"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Configuration
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 p-4">
<TabsContent value="info" className="mt-0 space-y-4">
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<Code className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">Model</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{agent.model || "Not specified"}
</span>
</div>
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<TagIcon className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">Tools</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{getToolsCount()}
</span>
</div>
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<Layers className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">
Sub-agents
</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{getSubAgentsCount()}
</span>
</div>
</div>
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Role
</h3>
<p className="text-neutral-300 text-sm">
{agent.role || "Not specified"}
</p>
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Goal
</h3>
<p className="text-neutral-300 text-sm">
{agent.goal || "Not specified"}
</p>
{/* agent instructions: agent.instruction */}
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Instructions
</h3>
<div className="bg-neutral-900 p-3 rounded-md border border-neutral-700 max-h-[200px] overflow-y-auto">
<pre className="text-xs text-neutral-300 whitespace-pre-wrap font-mono">
{agent.instruction || "No instructions provided"}
</pre>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="tools" className="mt-0 space-y-4">
{getToolsCount() > 0 ? (
<div className="space-y-3">
{/* Built-in tools */}
{agent.config?.tools && agent.config.tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Built-in Tools
</h3>
<div className="grid grid-cols-2 gap-2">
{agent.config.tools.map((tool, index) => (
<Badge
key={index}
variant="outline"
className="bg-neutral-900 border-neutral-700 text-neutral-300 p-2 justify-start"
>
<TagIcon className="h-3.5 w-3.5 mr-1.5 text-emerald-400" />
{typeof tool === 'string' ? tool : 'Custom Tool'}
</Badge>
))}
</div>
</div>
)}
{/* Agent Tools */}
{agent.config?.agent_tools && agent.config.agent_tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Tools
</h3>
<div className="space-y-2">
{agent.config.agent_tools.map((agentId, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<div className="flex items-center">
<Bot className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{agents.find(a => a.id === agentId)?.name || agentId}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Custom HTTP tools */}
{agent.config?.custom_tools?.http_tools &&
agent.config.custom_tools.http_tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Custom HTTP Tools
</h3>
<div className="space-y-2">
{agent.config.custom_tools.http_tools.map(
(tool, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center justify-between"
>
<div className="flex items-center">
<Server className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{tool.name}
</span>
</div>
<Badge
variant="outline"
className="text-xs bg-neutral-800 border-neutral-700 text-emerald-400"
>
{tool.method}
</Badge>
</div>
)
)}
</div>
</div>
)}
</div>
) : (
<div className="bg-neutral-800 p-6 rounded-md border border-neutral-700 text-center">
<TagIcon className="h-8 w-8 text-neutral-600 mx-auto mb-2" />
<p className="text-neutral-400">
This agent has no tools configured
</p>
</div>
)}
</TabsContent>
<TabsContent value="config" className="mt-0 space-y-4">
<div className="space-y-3">
{/* MCP Servers */}
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
MCP Servers
</h3>
{getMCPServersCount() > 0 ? (
<div className="space-y-2">
{agent.config?.mcp_servers &&
agent.config.mcp_servers.map((mcp, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<Server className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{mcp.id}
</span>
</div>
))}
{agent.config?.custom_mcp_servers &&
agent.config.custom_mcp_servers.map((mcp, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<Server className="h-3.5 w-3.5 mr-2 text-yellow-400" />
<span className="text-sm text-neutral-300">
{mcp.url}
</span>
</div>
))}
</div>
) : (
<p className="text-neutral-400 text-sm">
No MCP servers configured
</p>
)}
</div>
{/* API Key */}
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
API Key
</h3>
<p className="text-neutral-300 text-sm">
{agent.api_key_id
? `Key ID: ${agent.api_key_id}`
: "No API key configured"}
</p>
</div>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
<DialogFooter className="pt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-neutral-800 hover:bg-neutral-700 border-neutral-700 text-neutral-300"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Agent Edit Form Dialog */}
{isAgentFormOpen && agent && (
<AgentForm
open={isAgentFormOpen}
onOpenChange={setIsAgentFormOpen}
initialValues={agent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={agents}
onOpenApiKeysDialog={() => {}}
onOpenMCPDialog={() => {}}
onOpenCustomMCPDialog={() => {}}
onSave={handleSaveAgent}
isLoading={isLoading}
getAgentNameById={(id) => id}
clientId={clientId}
/>
)}
</>
);
}

View File

@ -0,0 +1,158 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/AttachedFiles.tsx
Developed by: Davidson Gomes
Creation date: August 24, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React from "react";
import { formatFileSize, isImageFile } from "@/lib/file-utils";
import { File, FileText, Download, Image } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
filename: string;
content_type: string;
data?: string;
size?: number;
preview_url?: string;
}
interface AttachedFilesProps {
files: AttachedFile[];
className?: string;
}
export function AttachedFiles({ files, className = "" }: AttachedFilesProps) {
if (!files || files.length === 0) return null;
const downloadFile = (file: AttachedFile) => {
if (!file.data) {
toast({
title: "File without data for download",
description: file.filename,
});
return;
}
try {
const link = document.createElement("a");
const dataUrl = file.data.startsWith("data:")
? file.data
: `data:${file.content_type};base64,${file.data}`;
link.href = dataUrl;
link.download = file.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast({
title: "Error downloading file",
description: file.filename,
});
}
};
return (
<div className={`flex flex-col gap-2 mt-2 ${className}`}>
<div className="text-xs text-neutral-400 mb-1">Attached files:</div>
<div className="flex flex-wrap gap-2">
{files
.map((file, index) => {
if (!file.data) {
toast({
title: "File without data for display",
description: file.filename,
});
return null;
}
return (
<div
key={index}
className="flex flex-col bg-[#333] rounded-md overflow-hidden border border-[#444] hover:border-[#666] transition-colors"
>
{isImageFile(file.content_type) && file.data && (
<div className="w-full max-w-[200px] h-[120px] bg-black flex items-center justify-center">
<img
src={
file.preview_url ||
(file.data.startsWith("data:")
? file.data
: `data:${file.content_type};base64,${file.data}`)
}
alt={file.filename}
className="max-w-full max-h-full object-contain"
onError={(e) => {
toast({
title: "Error loading image",
description: file.filename,
});
(e.target as HTMLImageElement).src =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1pbWFnZS1vZmYiPjxsaW5lIHgxPSIyIiB5MT0iMiIgeDI9IjIyIiB5Mj0iMjIiLz48PHJlY3QgdyA";
}}
/>
</div>
)}
<div className="p-2 flex items-center gap-2">
<div className="flex-shrink-0">
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === "application/pdf" ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate max-w-[150px]">
{file.filename}
</div>
{file.size && (
<div className="text-[10px] text-neutral-400">
{formatFileSize(file.size)}
</div>
)}
</div>
{file.data && (
<button
onClick={() => downloadFile(file)}
className="text-emerald-400 hover:text-white transition-colors"
title="Download"
>
<Download className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})
.filter(Boolean)}
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/ChatContainer.tsx
Developed by: Davidson Gomes
Creation date: May 14, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React, { useRef, useEffect, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageSquare, Loader2, Bot, Zap } from "lucide-react";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatMessage as ChatMessageType } from "@/services/sessionService";
import { cn } from "@/lib/utils";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatContainerProps {
messages: ChatMessageType[];
isLoading: boolean;
onSendMessage: (message: string) => void;
agentColor: string;
expandedFunctions: Record<string, boolean>;
toggleFunctionExpansion: (messageId: string) => void;
containsMarkdown: (text: string) => boolean;
getMessageText: (message: ChatMessageType) => string | FunctionMessageContent;
agentName?: string;
containerClassName?: string;
messagesContainerClassName?: string;
inputContainerClassName?: string;
sessionId?: string;
}
export function ChatContainer({
messages,
isLoading,
onSendMessage,
agentColor,
expandedFunctions,
toggleFunctionExpansion,
containsMarkdown,
getMessageText,
agentName = "Agent",
containerClassName = "",
messagesContainerClassName = "",
inputContainerClassName = "",
sessionId,
}: ChatContainerProps) {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isInitializing, setIsInitializing] = useState(false);
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
};
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [messages]);
// Simulate initial loading for smoother UX
useEffect(() => {
if (sessionId) {
setIsInitializing(true);
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [sessionId]);
const isEmpty = messages.length === 0;
return (
<div className={cn(
"flex-1 flex flex-col overflow-hidden bg-gradient-to-b from-neutral-900 to-neutral-950",
containerClassName
)}>
<div
className={cn(
"flex-1 overflow-hidden p-5",
messagesContainerClassName
)}
style={{ filter: isLoading && !isInitializing ? "blur(1px)" : "none" }}
>
<ScrollArea
ref={messagesContainerRef}
className="h-full pr-4"
>
{isInitializing ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 animate-pulse">
<Zap className="h-5 w-5 text-white" />
</div>
<p className="text-neutral-400 mb-2">Loading conversation...</p>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '0ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '150ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '300ms' }}></span>
</div>
</div>
) : isEmpty ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500/20 to-emerald-500/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">
{`Chat with ${agentName}`}
</h3>
<p className="text-neutral-500 text-sm max-w-md">
Type your message below to start the conversation. This chat will help you interact with the agent and explore its capabilities.
</p>
</div>
) : (
<div className="space-y-6 py-4 flex-1">
{messages.map((message) => {
const messageContent = getMessageText(message);
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessage
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
sessionId={sessionId}
/>
);
})}
</div>
)}
</ScrollArea>
</div>
<div className={cn(
"p-3 border-t border-neutral-800 bg-neutral-900",
inputContainerClassName
)}>
{isLoading && !isInitializing && (
<div className="px-4 py-2 mb-3 rounded-lg bg-neutral-800/50 text-sm text-neutral-400 flex items-center">
<Loader2 className="h-3 w-3 mr-2 animate-spin text-emerald-400" />
Agent is thinking...
</div>
)}
<ChatInput
onSendMessage={onSendMessage}
isLoading={isLoading}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,270 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/ChatInput.tsx
Developed by: Davidson Gomes
Creation date: May 14, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React, { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Send, Paperclip, X, Image, FileText, File } from "lucide-react";
import { FileData, formatFileSize, isImageFile } from "@/lib/file-utils";
import { toast } from "@/hooks/use-toast";
interface ChatInputProps {
onSendMessage: (message: string, files?: FileData[]) => void;
isLoading?: boolean;
placeholder?: string;
className?: string;
buttonClassName?: string;
containerClassName?: string;
autoFocus?: boolean;
}
export function ChatInput({
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className = "",
buttonClassName = "",
containerClassName = "",
autoFocus = true,
}: ChatInputProps) {
const [messageInput, setMessageInput] = useState("");
const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]);
const [resetFileUpload, setResetFileUpload] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Autofocus the textarea when the component is mounted
React.useEffect(() => {
// Small timeout to ensure focus is applied after the complete rendering
if (autoFocus) {
const timer = setTimeout(() => {
if (textareaRef.current && !isLoading) {
textareaRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}
}, [isLoading, autoFocus]);
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim() && selectedFiles.length === 0) return;
onSendMessage(messageInput, selectedFiles.length > 0 ? selectedFiles : undefined);
setMessageInput("");
setSelectedFiles([]);
setResetFileUpload(true);
setTimeout(() => {
setResetFileUpload(false);
// Keep the focus on the textarea after sending the message
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, 100);
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e as unknown as React.FormEvent);
}
};
const autoResizeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
textarea.style.height = "auto";
const maxHeight = 10 * 24;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
setMessageInput(textarea.value);
};
const handleFilesSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const newFiles = Array.from(e.target.files);
const maxFileSize = 10 * 1024 * 1024; // 10MB
if (selectedFiles.length + newFiles.length > 5) {
toast({
title: `You can only attach up to 5 files.`,
variant: "destructive",
});
return;
}
const validFiles: FileData[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: `File ${file.name} exceeds the maximum size of ${formatFileSize(maxFileSize)}.`,
variant: "destructive",
});
continue;
}
try {
const reader = new FileReader();
const readFile = new Promise<string>((resolve, reject) => {
reader.onload = () => {
const base64 = reader.result as string;
const base64Data = base64.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
});
reader.readAsDataURL(file);
const base64Data = await readFile;
const previewUrl = URL.createObjectURL(file);
validFiles.push({
filename: file.name,
content_type: file.type,
data: base64Data,
size: file.size,
preview_url: previewUrl
});
} catch (error) {
console.error("Error processing file:", error);
toast({
title: `Error processing file ${file.name}`,
variant: "destructive",
});
}
}
if (validFiles.length > 0) {
const updatedFiles = [...selectedFiles, ...validFiles];
setSelectedFiles(updatedFiles);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const openFileSelector = () => {
fileInputRef.current?.click();
};
return (
<div className={`flex flex-col w-full ${containerClassName}`}>
{selectedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 mb-3 mt-1">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-gradient-to-br from-neutral-800 to-neutral-900 text-white rounded-lg p-2 text-xs border border-neutral-700/50 shadow-sm"
>
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[120px] truncate">{file.filename}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => {
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(updatedFiles);
}}
className="ml-1 text-neutral-400 hover:text-white transition-colors bg-neutral-700/30 rounded-full p-1"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<form
onSubmit={handleSendMessage}
className="flex w-full items-center gap-2 px-2"
>
{selectedFiles.length < 5 && (
<button
onClick={(e) => {
e.preventDefault();
openFileSelector();
}}
type="button"
className="flex items-center justify-center w-9 h-9 rounded-full hover:bg-neutral-800/60 text-neutral-400 hover:text-emerald-400 transition-all border border-neutral-700/30"
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</button>
)}
<Textarea
value={messageInput}
onChange={autoResizeTextarea}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`flex-1 bg-neutral-800/40 border-neutral-700/50 text-white focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 min-h-[40px] max-h-[240px] resize-none rounded-xl ${className}`}
disabled={isLoading}
rows={1}
ref={textareaRef}
/>
<Button
type="submit"
disabled={isLoading || (!messageInput.trim() && selectedFiles.length === 0)}
className={`bg-gradient-to-r from-emerald-500 to-emerald-600 text-white hover:from-emerald-600 hover:to-emerald-700 rounded-full shadow-md h-9 w-9 p-0 ${buttonClassName}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFilesSelected}
className="hidden"
multiple
/>
</form>
</div>
);
}

View File

@ -0,0 +1,375 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/ChatMessage.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChatMessage as ChatMessageType } from "@/services/sessionService";
import { ChevronDown, ChevronRight, Copy, Check, User, Bot, Terminal } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useState } from "react";
import { InlineDataAttachments } from "./InlineDataAttachments";
import { cn } from "@/lib/utils";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface AttachedFile {
filename: string;
content_type: string;
data: string;
size: number;
preview_url?: string;
}
interface ChatMessageProps {
message: ChatMessageType;
agentColor: string;
isExpanded: boolean;
toggleExpansion: (messageId: string) => void;
containsMarkdown: (text: string) => boolean;
messageContent: string | FunctionMessageContent;
sessionId?: string;
}
export function ChatMessage({
message,
agentColor,
isExpanded,
toggleExpansion,
containsMarkdown,
messageContent,
sessionId,
}: ChatMessageProps) {
const [isCopied, setIsCopied] = useState(false);
const isUser = message.author === "user";
const hasFunctionCall = message.content.parts.some(
(part) => part.functionCall || part.function_call
);
const hasFunctionResponse = message.content.parts.some(
(part) => part.functionResponse || part.function_response
);
const isFunctionMessage = hasFunctionCall || hasFunctionResponse;
const isTaskExecutor = typeof messageContent === "object" &&
"author" in messageContent &&
typeof messageContent.author === "string" &&
messageContent.author.endsWith("- Task executor");
const inlineDataParts = message.content.parts.filter(part => part.inline_data);
const hasInlineData = inlineDataParts.length > 0;
const copyToClipboard = () => {
const textToCopy = typeof messageContent === "string"
? messageContent
: messageContent.content;
navigator.clipboard.writeText(textToCopy).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
};
// Generate appropriate avatar content
const getAvatar = () => {
if (isUser) {
return (
<Avatar className="bg-gradient-to-br from-emerald-500 to-emerald-700 shadow-md border-0">
<AvatarFallback className="bg-transparent">
<User className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
);
} else {
return (
<Avatar className={`shadow-md border-0 ${
isFunctionMessage
? "bg-gradient-to-br from-emerald-600 to-emerald-800"
: "bg-gradient-to-br from-purple-600 to-purple-800"
}`}>
<AvatarFallback className="bg-transparent">
{isFunctionMessage ?
<Terminal className="h-4 w-4 text-white" /> :
<Bot className="h-4 w-4 text-white" />
}
</AvatarFallback>
</Avatar>
);
}
};
return (
<div
key={message.id}
className="flex w-full"
style={{
justifyContent: isUser ? "flex-end" : "flex-start"
}}
>
<div
className="flex gap-3 max-w-[90%]"
style={{
flexDirection: isUser ? "row-reverse" : "row"
}}
>
{getAvatar()}
<div
className={`rounded-lg p-3 overflow-hidden relative group shadow-md ${
isFunctionMessage || isTaskExecutor
? "bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 text-emerald-300 font-mono text-sm"
: isUser
? "bg-emerald-500 text-white"
: "bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 text-white"
}`}
style={{
wordBreak: "break-word",
maxWidth: "calc(100% - 3rem)",
width: "100%"
}}
>
{isFunctionMessage || isTaskExecutor ? (
<div className="w-full">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-[#444] rounded px-1 py-0.5 transition-colors"
onClick={() => toggleExpansion(message.id)}
>
{typeof messageContent === "object" &&
"title" in messageContent && (
<>
<div className="flex-1 font-semibold">
{(messageContent as FunctionMessageContent).title}
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
{isTaskExecutor && (
<>
<div className="flex-1 font-semibold">
Task Execution
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
</div>
{isExpanded && (
<div className="mt-2 pt-2 border-t border-[#555]">
{typeof messageContent === "object" &&
"content" in messageContent && (
<div className="max-w-full overflow-x-auto">
<pre className="whitespace-pre-wrap text-xs max-w-full" style={{
wordWrap: "break-word",
maxWidth: "100%",
wordBreak: "break-all"
}}>
{(messageContent as FunctionMessageContent).content}
</pre>
</div>
)}
</div>
)}
</div>
) : (
<div className="markdown-content break-words max-w-full overflow-x-auto">
{typeof messageContent === "object" &&
"author" in messageContent &&
messageContent.author !== "user" &&
!isTaskExecutor && (
<div className="text-xs text-neutral-400 mb-1">
{messageContent.author}
</div>
)}
{((typeof messageContent === "string" &&
containsMarkdown(messageContent)) ||
(typeof messageContent === "object" &&
"content" in messageContent &&
typeof messageContent.content === "string" &&
containsMarkdown(messageContent.content))) &&
!isTaskExecutor ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1 className="text-xl font-bold my-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-lg font-bold my-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-base font-bold my-2" {...props} />
),
h4: ({ ...props }) => (
<h4 className="font-semibold my-2" {...props} />
),
p: ({ ...props }) => <p className="mb-3" {...props} />,
ul: ({ ...props }) => (
<ul
className="list-disc pl-6 mb-3 space-y-1"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal pl-6 mb-3 space-y-1"
{...props}
/>
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
a: ({ ...props }) => (
<a
className="text-emerald-300 underline hover:opacity-80 transition-opacity"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-[#444] pl-4 py-1 italic my-3 text-neutral-300"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline =
!match &&
typeof children === "string" &&
!children.includes("\n");
if (isInline) {
return (
<code
className="bg-[#333] px-1.5 py-0.5 rounded text-emerald-300 text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<div className="my-3 relative group/code">
<div className="bg-[#1a1a1a] rounded-t-md border-b border-[#333] p-2 text-xs text-neutral-400 flex justify-between items-center">
<span>{match?.[1] || "Code"}</span>
<button
onClick={copyToClipboard}
className="text-neutral-400 hover:text-emerald-300 transition-colors"
title="Copy code"
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre className="bg-[#1a1a1a] p-3 rounded-b-md overflow-x-auto whitespace-pre text-sm">
<code {...props}>{children}</code>
</pre>
</div>
);
},
table: ({ ...props }) => (
<div className="overflow-x-auto my-3">
<table
className="min-w-full border border-[#333] rounded"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead className="bg-[#1a1a1a]" {...props} />
),
tbody: ({ ...props }) => <tbody {...props} />,
tr: ({ ...props }) => (
<tr
className="border-b border-[#333] last:border-0"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-2 text-left text-xs font-semibold text-neutral-300"
{...props}
/>
),
td: ({ ...props }) => (
<td className="px-4 py-2 text-sm" {...props} />
),
}}
>
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap">
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</div>
)}
{hasInlineData && (
<InlineDataAttachments parts={inlineDataParts} sessionId={sessionId} />
)}
</div>
)}
<button
onClick={copyToClipboard}
className="absolute top-2 right-2 p-1.5 rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-neutral-700/80"
title="Copy message"
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,185 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/FileUpload.tsx
Developed by: Davidson Gomes
Creation date: August 24, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React, { useState, useRef, useEffect } from "react";
import { FileData, formatFileSize, isImageFile } from "@/lib/file-utils";
import { Paperclip, X, Image, File, FileText } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface FileUploadProps {
onFilesSelected: (files: FileData[]) => void;
maxFileSize?: number;
maxFiles?: number;
className?: string;
reset?: boolean;
}
export function FileUpload({
onFilesSelected,
maxFileSize = 10 * 1024 * 1024, // 10MB
maxFiles = 5,
className = "",
reset = false, // Default false
}: FileUploadProps) {
const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (reset && selectedFiles.length > 0) {
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}, [reset]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const newFiles = Array.from(e.target.files);
if (selectedFiles.length + newFiles.length > maxFiles) {
toast({
title: `You can only attach up to ${maxFiles} files.`,
variant: "destructive",
});
return;
}
const validFiles: FileData[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: `File ${file.name} exceeds the maximum size of ${formatFileSize(maxFileSize)}.`,
variant: "destructive",
});
continue;
}
try {
const reader = new FileReader();
const readFile = new Promise<string>((resolve, reject) => {
reader.onload = () => {
const base64 = reader.result as string;
const base64Data = base64.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
});
reader.readAsDataURL(file);
const base64Data = await readFile;
const previewUrl = URL.createObjectURL(file);
validFiles.push({
filename: file.name,
content_type: file.type,
data: base64Data,
size: file.size,
preview_url: previewUrl
});
} catch (error) {
console.error("Error processing file:", error);
toast({
title: `Error processing file ${file.name}`,
variant: "destructive",
});
}
}
if (validFiles.length > 0) {
const updatedFiles = [...selectedFiles, ...validFiles];
setSelectedFiles(updatedFiles);
onFilesSelected(updatedFiles);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const removeFile = (index: number) => {
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(updatedFiles);
onFilesSelected(updatedFiles);
};
return (
<div className={`flex gap-2 items-center ${className}`}>
{selectedFiles.length > 0 && (
<div className="flex gap-2 flex-wrap items-center flex-1">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1 bg-[#333] text-white rounded-md p-1.5 text-xs group relative"
>
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[120px] truncate">{file.filename}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{selectedFiles.length < maxFiles && (
<button
onClick={() => fileInputRef.current?.click()}
type="button"
className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#333] text-neutral-400 hover:text-emerald-400 transition-colors"
title="Attach file"
>
<Paperclip className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,182 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/InlineDataAttachments.tsx
Developed by: Davidson Gomes
Creation date: August 29, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import React, { useState, useEffect } from "react";
import { formatFileSize, isImageFile } from "@/lib/file-utils";
import { File, FileText, Download, Image } from "lucide-react";
import { ChatPart } from "@/services/sessionService";
interface InlineDataAttachmentsProps {
parts: ChatPart[];
className?: string;
sessionId?: string;
}
interface ProcessedFile {
filename: string;
content_type: string;
data: string;
size: number;
preview_url?: string;
}
export function InlineDataAttachments({ parts, className = "", sessionId }: InlineDataAttachmentsProps) {
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const [isProcessed, setIsProcessed] = useState(false);
useEffect(() => {
if (isProcessed) return;
const validParts = parts.filter(part => part.inline_data && part.inline_data.data);
if (validParts.length === 0) {
setIsProcessed(true);
return;
}
const files = validParts.map((part, index) => {
const { mime_type, data } = part.inline_data!;
const extension = mime_type.split('/')[1] || 'file';
let filename = '';
if (part.inline_data?.metadata?.filename) {
filename = part.inline_data.metadata.filename;
}
else if (part.file_data?.filename) {
filename = part.file_data.filename;
}
else {
filename = `media_${index + 1}.${extension}`;
}
let preview_url = undefined;
if (data && isImageFile(mime_type)) {
preview_url = data.startsWith('data:')
? data
: `data:${mime_type};base64,${data}`;
}
const fileData: ProcessedFile = {
filename,
content_type: mime_type,
size: data.length,
data,
preview_url
};
return fileData;
});
setProcessedFiles(files);
setIsProcessed(true);
}, [parts, isProcessed]);
if (processedFiles.length === 0) return null;
const downloadFile = (file: ProcessedFile) => {
try {
const link = document.createElement("a");
const dataUrl = file.data.startsWith('data:')
? file.data
: `data:${file.content_type};base64,${file.data}`;
link.href = dataUrl;
link.download = file.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error(`Error downloading file ${file.filename}:`, error);
}
};
const getFileUrl = (file: ProcessedFile) => {
return file.preview_url || (file.data.startsWith('data:')
? file.data
: `data:${file.content_type};base64,${file.data}`);
};
return (
<div className={`flex flex-col gap-2 mt-2 ${className}`}>
<div className="text-xs text-neutral-400 mb-1">
<span>Attached files:</span>
</div>
<div className="flex flex-wrap gap-2">
{processedFiles.map((file, index) => (
<div
key={index}
className="flex flex-col bg-[#333] rounded-md overflow-hidden border border-[#444] hover:border-[#666] transition-colors"
>
{isImageFile(file.content_type) && (
<div className="w-full max-w-[200px] h-[120px] bg-black flex items-center justify-center">
<img
src={getFileUrl(file)}
alt={file.filename}
className="max-w-full max-h-full object-contain"
onError={(e) => {
console.error(`Error loading image ${file.filename}`);
(e.target as HTMLImageElement).src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1pbWFnZS1vZmYiPjxsaW5lIHgxPSIyIiB5MT0iMiIgeDI9IjIyIiB5Mj0iMjIiLz48cmVjdCB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHg9IjIiIHk9IjIiIHJ4PSIyIiByeT0iMiIvPjxsaW5lIHgxPSI4IiB5MT0iMTAiIHgyPSI4IiB5Mj0iMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNCIgeDI9IjEyIiB5Mj0iMTQiLz48L3N2Zz4=";
}}
/>
</div>
)}
<div className="p-2 flex items-center gap-2">
<div className="flex-shrink-0">
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === "application/pdf" ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate max-w-[150px]">
{file.filename}
</div>
<div className="text-[10px] text-neutral-400">
{formatFileSize(file.size)}
</div>
</div>
<button
onClick={() => downloadFile(file)}
className="text-emerald-400 hover:text-white transition-colors"
title="Download"
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,248 @@
/*
@author: Davidson Gomes
@file: /app/chat/components/SessionList.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Search, Filter, Plus, Loader2 } from "lucide-react";
import { ChatSession } from "@/services/sessionService";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface SessionListProps {
sessions: ChatSession[];
agents: any[];
selectedSession: string | null;
isLoading: boolean;
searchTerm: string;
selectedAgentFilter: string;
showAgentFilter: boolean;
setSearchTerm: (value: string) => void;
setSelectedAgentFilter: (value: string) => void;
setShowAgentFilter: (value: boolean) => void;
setSelectedSession: (value: string | null) => void;
setIsNewChatDialogOpen: (value: boolean) => void;
}
export function SessionList({
sessions,
agents,
selectedSession,
isLoading,
searchTerm,
selectedAgentFilter,
showAgentFilter,
setSearchTerm,
setSelectedAgentFilter,
setShowAgentFilter,
setSelectedSession,
setIsNewChatDialogOpen,
}: SessionListProps) {
const filteredSessions = sessions.filter((session) => {
const matchesSearchTerm = session.id
.toLowerCase()
.includes(searchTerm.toLowerCase());
if (selectedAgentFilter === "all") {
return matchesSearchTerm;
}
const sessionAgentId = session.id.split("_")[1];
return matchesSearchTerm && sessionAgentId === selectedAgentFilter;
});
const sortedSessions = [...filteredSessions].sort((a, b) => {
const updateTimeA = new Date(a.update_time).getTime();
const updateTimeB = new Date(b.update_time).getTime();
return updateTimeB - updateTimeA;
});
const formatDateTime = (dateTimeStr: string) => {
try {
const date = new Date(dateTimeStr);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
return "Invalid date";
}
};
const getExternalId = (sessionId: string) => {
return sessionId.split("_")[0];
};
return (
<div className="w-64 border-r border-neutral-700 flex flex-col bg-neutral-900">
<div className="p-4 border-b border-neutral-700">
<div className="flex items-center justify-between mb-4">
<Button
onClick={() => setIsNewChatDialogOpen(true)}
className="bg-emerald-800 text-emerald-100 hover:bg-emerald-700 border-emerald-700"
size="sm"
>
<Plus className="h-4 w-4 mr-1" /> New Conversation
</Button>
</div>
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search conversations..."
className="pl-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus-visible:ring-emerald-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
onClick={() => setShowAgentFilter(!showAgentFilter)}
>
<Filter className="h-4 w-4 mr-1" />
Filter
</Button>
{selectedAgentFilter !== "all" && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedAgentFilter("all")}
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
>
Clear filter
</Button>
)}
</div>
{showAgentFilter && (
<div className="pt-1">
<Select
value={selectedAgentFilter}
onValueChange={setSelectedAgentFilter}
>
<SelectTrigger className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Filter by agent" />
</SelectTrigger>
<SelectContent className="bg-neutral-900 border-neutral-700 text-white">
<SelectItem
value="all"
className="data-[selected]:bg-neutral-800 data-[highlighted]:bg-neutral-800 !text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
All agents
</SelectItem>
{agents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="data-[selected]:bg-neutral-800 data-[highlighted]:bg-neutral-800 !text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
{agent.name.slice(0, 15)}{" "}
{agent.name.length > 15 && "..."}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{isLoading ? (
<div className="flex justify-center items-center h-24">
<Loader2 className="h-5 w-5 text-emerald-400 animate-spin" />
</div>
) : sortedSessions.length > 0 ? (
<div className="px-4 pt-2 space-y-2">
{sortedSessions.map((session) => {
const agentId = session.id.split("_")[1];
const agentInfo = agents.find((a) => a.id === agentId);
const externalId = getExternalId(session.id);
return (
<div
key={session.id}
className={`p-3 rounded-md cursor-pointer transition-colors group relative ${
selectedSession === session.id
? "bg-emerald-800/20 border border-emerald-600/40"
: "bg-neutral-800 hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => setSelectedSession(session.id)}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></div>
<div className="text-neutral-200 font-medium truncate max-w-[180px]">
{externalId}
</div>
</div>
<div className="mt-1 flex items-center gap-2">
{agentInfo && (
<Badge className="bg-neutral-700 text-emerald-400 border-neutral-600 text-xs">
{agentInfo.name.slice(0, 15)}
{agentInfo.name.length > 15 && "..."}
</Badge>
)}
<div className="text-xs text-neutral-500 ml-auto">
{formatDateTime(session.update_time)}
</div>
</div>
</div>
);
})}
</div>
) : searchTerm || selectedAgentFilter !== "all" ? (
<div className="text-center py-4 text-neutral-400">
No results found
</div>
) : (
<div className="text-center py-4 text-neutral-400">
Click "New" to start
</div>
)}
</div>
</div>
);
}

881
frontend/app/chat/page.tsx Normal file
View File

@ -0,0 +1,881 @@
/*
@author: Davidson Gomes
@file: /app/chat/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
MessageSquare,
Send,
Plus,
Search,
Loader2,
X,
Trash2,
Bot,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { listAgents } from "@/services/agentService";
import {
listSessions,
getSessionMessages,
ChatMessage,
deleteSession,
ChatSession,
ChatPart
} from "@/services/sessionService";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAgentWebSocket } from "@/hooks/use-agent-webSocket";
import { getAccessTokenFromCookie } from "@/lib/utils";
import { ChatMessage as ChatMessageComponent } from "./components/ChatMessage";
import { SessionList } from "./components/SessionList";
import { ChatInput } from "./components/ChatInput";
import { FileData } from "@/lib/file-utils";
import { AgentInfoDialog } from "./components/AgentInfoDialog";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
export default function Chat() {
const [isLoading, setIsLoading] = useState(true);
const [agents, setAgents] = useState<any[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [agentSearchTerm, setAgentSearchTerm] = useState("");
const [selectedAgentFilter, setSelectedAgentFilter] = useState<string>("all");
const [messageInput, setMessageInput] = useState("");
const [selectedSession, setSelectedSession] = useState<string | null>(null);
const [currentAgentId, setCurrentAgentId] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [isNewChatDialogOpen, setIsNewChatDialogOpen] = useState(false);
const [showAgentFilter, setShowAgentFilter] = useState(false);
const [expandedFunctions, setExpandedFunctions] = useState<
Record<string, boolean>
>({});
const [isAgentInfoDialogOpen, setIsAgentInfoDialogOpen] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "test";
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
};
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
try {
const agentsResponse = await listAgents(clientId);
setAgents(agentsResponse.data);
const sessionsResponse = await listSessions(clientId);
setSessions(sessionsResponse.data);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setIsLoading(false);
}
};
loadData();
}, [clientId]);
useEffect(() => {
if (!selectedSession) {
setMessages([]);
return;
}
const loadMessages = async () => {
try {
setIsLoading(true);
const response = await getSessionMessages(selectedSession);
setMessages(response.data);
const agentId = selectedSession.split("_")[1];
setCurrentAgentId(agentId);
setTimeout(scrollToBottom, 100);
} catch (error) {
console.error("Error loading messages:", error);
} finally {
setIsLoading(false);
}
};
loadMessages();
}, [selectedSession]);
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [messages]);
const filteredSessions = sessions.filter((session) => {
const matchesSearchTerm = session.id
.toLowerCase()
.includes(searchTerm.toLowerCase());
if (selectedAgentFilter === "all") {
return matchesSearchTerm;
}
const sessionAgentId = session.id.split("_")[1];
return matchesSearchTerm && sessionAgentId === selectedAgentFilter;
});
const sortedSessions = [...filteredSessions].sort((a, b) => {
const updateTimeA = new Date(a.update_time).getTime();
const updateTimeB = new Date(b.update_time).getTime();
return updateTimeB - updateTimeA;
});
const formatDateTime = (dateTimeStr: string) => {
try {
const date = new Date(dateTimeStr);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
return "Invalid date";
}
};
const filteredAgents = agents.filter(
(agent) =>
agent.name.toLowerCase().includes(agentSearchTerm.toLowerCase()) ||
(agent.description &&
agent.description.toLowerCase().includes(agentSearchTerm.toLowerCase()))
);
const selectAgent = (agentId: string) => {
setCurrentAgentId(agentId);
setSelectedSession(null);
setMessages([]);
setIsNewChatDialogOpen(false);
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim() || !currentAgentId) return;
setIsSending(true);
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: [{ text: messageInput }],
role: "user",
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(messageInput);
setMessageInput("");
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const handleSendMessageWithFiles = (message: string, files?: FileData[]) => {
if ((!message.trim() && (!files || files.length === 0)) || !currentAgentId)
return;
setIsSending(true);
const messageParts: ChatPart[] = [];
if (message.trim()) {
messageParts.push({ text: message });
}
if (files && files.length > 0) {
files.forEach(file => {
messageParts.push({
inline_data: {
data: file.data,
mime_type: file.content_type,
metadata: {
filename: file.filename
}
}
});
});
}
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: messageParts,
role: "user"
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(message, files);
setMessageInput("");
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const generateExternalId = () => {
const now = new Date();
return (
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, "0") +
now.getDate().toString().padStart(2, "0") +
now.getHours().toString().padStart(2, "0") +
now.getMinutes().toString().padStart(2, "0") +
now.getSeconds().toString().padStart(2, "0") +
now.getMilliseconds().toString().padStart(3, "0")
);
};
const currentAgent = agents.find((agent) => agent.id === currentAgentId);
const getCurrentSessionInfo = () => {
if (!selectedSession) return null;
const parts = selectedSession.split("_");
try {
const dateStr = parts[0];
if (dateStr.length >= 8) {
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return {
externalId: parts[0],
agentId: parts[1],
displayDate: `${day}/${month}/${year}`,
};
}
} catch (e) {
console.error("Error processing session ID:", e);
}
return {
externalId: parts[0],
agentId: parts[1],
displayDate: "Session",
};
};
const getExternalId = (sessionId: string) => {
return sessionId.split("_")[0];
};
const containsMarkdown = (text: string): boolean => {
if (!text || text.length < 3) return false;
const markdownPatterns = [
/[*_]{1,2}[^*_]+[*_]{1,2}/, // bold/italic
/\[[^\]]+\]\([^)]+\)/, // links
/^#{1,6}\s/m, // headers
/^[-*+]\s/m, // unordered lists
/^[0-9]+\.\s/m, // ordered lists
/^>\s/m, // block quotes
/`[^`]+`/, // inline code
/```[\s\S]*?```/, // code blocks
/^\|(.+\|)+$/m, // tables
/!\[[^\]]*\]\([^)]+\)/, // images
];
return markdownPatterns.some((pattern) => pattern.test(text));
};
const getMessageText = (
message: ChatMessage
): string | FunctionMessageContent => {
const author = message.author;
const parts = message.content.parts;
if (!parts || parts.length === 0) return "Empty content";
const functionCallPart = parts.find(
(part) => part.functionCall || part.function_call
);
const functionResponsePart = parts.find(
(part) => part.functionResponse || part.function_response
);
const inlineDataParts = parts.filter((part) => part.inline_data);
if (functionCallPart) {
const funcCall =
functionCallPart.functionCall || functionCallPart.function_call || {};
const args = funcCall.args || {};
const name = funcCall.name || "unknown";
const id = funcCall.id || "no-id";
return {
author,
title: `📞 Function call: ${name}`,
content: `ID: ${id}
Args: ${
Object.keys(args).length > 0
? `\n${JSON.stringify(args, null, 2)}`
: "{}"
}`,
} as FunctionMessageContent;
}
if (functionResponsePart) {
const funcResponse =
functionResponsePart.functionResponse ||
functionResponsePart.function_response ||
{};
const response = funcResponse.response || {};
const name = funcResponse.name || "unknown";
const id = funcResponse.id || "no-id";
const status = response.status || "unknown";
const statusEmoji = status === "error" ? "❌" : "✅";
let resultText = "";
if (status === "error") {
resultText = `Error: ${response.error_message || "Unknown error"}`;
} else if (response.report) {
resultText = `Result: ${response.report}`;
} else if (response.result && response.result.content) {
const content = response.result.content;
if (Array.isArray(content) && content.length > 0 && content[0].text) {
try {
const textContent = content[0].text;
const parsedJson = JSON.parse(textContent);
resultText = `Result: \n${JSON.stringify(parsedJson, null, 2)}`;
} catch (e) {
resultText = `Result: ${content[0].text}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
return {
author,
title: `${statusEmoji} Function response: ${name}`,
content: `ID: ${id}\n${resultText}`,
} as FunctionMessageContent;
}
if (parts.length === 1 && parts[0].text) {
return {
author,
content: parts[0].text,
title: "Message",
} as FunctionMessageContent;
}
const textParts = parts
.filter((part) => part.text)
.map((part) => part.text)
.filter((text) => text);
if (textParts.length > 0) {
return {
author,
content: textParts.join("\n\n"),
title: "Message",
} as FunctionMessageContent;
}
return "Empty content";
};
const toggleFunctionExpansion = (messageId: string) => {
setExpandedFunctions((prev) => ({
...prev,
[messageId]: !prev[messageId],
}));
};
const agentColors: Record<string, string> = {
Assistant: "bg-emerald-400",
Programmer: "bg-[#00cc7d]",
Writer: "bg-[#00b8ff]",
Researcher: "bg-[#ff9d00]",
Planner: "bg-[#9d00ff]",
default: "bg-[#333]",
};
const getAgentColor = (agentName: string) => {
return agentColors[agentName] || agentColors.default;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e as unknown as React.FormEvent);
}
};
const autoResizeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
textarea.style.height = "auto";
const maxHeight = 10 * 24;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
setMessageInput(textarea.value);
};
const handleDeleteSession = async () => {
if (!selectedSession) return;
try {
await deleteSession(selectedSession);
setSessions(sessions.filter((session) => session.id !== selectedSession));
setSelectedSession(null);
setMessages([]);
setCurrentAgentId(null);
setIsDeleteDialogOpen(false);
toast({
title: "Session deleted successfully",
});
} catch (error) {
console.error("Error deleting session:", error);
toast({
title: "Error deleting session",
variant: "destructive",
});
}
};
const onEvent = useCallback((event: any) => {
setMessages((prev) => [...prev, event]);
}, []);
const onTurnComplete = useCallback(() => {
setIsSending(false);
}, []);
const handleAgentInfoClick = () => {
setIsAgentInfoDialogOpen(true);
};
const handleAgentUpdated = (updatedAgent: any) => {
setAgents(agents.map(agent =>
agent.id === updatedAgent.id ? updatedAgent : agent
));
toast({
title: "Agent updated successfully",
description: "The agent has been updated with the new settings.",
});
};
const jwt = getAccessTokenFromCookie();
const agentId = useMemo(() => currentAgentId || "", [currentAgentId]);
const externalId = useMemo(
() =>
selectedSession ? getExternalId(selectedSession) : generateExternalId(),
[selectedSession]
);
const { sendMessage: wsSendMessage, disconnect: _ } = useAgentWebSocket({
agentId,
externalId,
jwt,
onEvent,
onTurnComplete,
});
return (
<div className="flex h-screen max-h-screen bg-[#121212]">
<SessionList
sessions={sessions}
agents={agents}
selectedSession={selectedSession}
isLoading={isLoading}
searchTerm={searchTerm}
selectedAgentFilter={selectedAgentFilter}
showAgentFilter={showAgentFilter}
setSearchTerm={setSearchTerm}
setSelectedAgentFilter={setSelectedAgentFilter}
setShowAgentFilter={setShowAgentFilter}
setSelectedSession={setSelectedSession}
setIsNewChatDialogOpen={setIsNewChatDialogOpen}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{selectedSession || currentAgentId ? (
<>
<div className="p-4 border-b border-[#333] bg-neutral-900 shadow-md">
{(() => {
const sessionInfo = getCurrentSessionInfo();
return (
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<div className="p-1 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
{selectedSession
? `Session ${
sessionInfo?.externalId || selectedSession
}`
: "New Conversation"}
</h2>
<div className="flex items-center gap-2">
{currentAgent && (
<Badge
className="bg-emerald-500 text-white px-3 py-1 text-sm border-0 cursor-pointer hover:bg-emerald-600 transition-colors"
onClick={handleAgentInfoClick}
>
{currentAgent.name || currentAgentId}
</Badge>
)}
{selectedSession && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-neutral-400 hover:text-red-500 hover:bg-[#333]"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})()}
</div>
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 bg-neutral-950"
>
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 relative">
<Loader2 className="h-6 w-6 text-white animate-spin" />
<div className="absolute inset-0 rounded-full blur-md bg-emerald-400/20 animate-pulse"></div>
</div>
<p className="text-neutral-400 mb-2">Loading conversation...</p>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col h-full items-center justify-center text-center p-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-700/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">
{currentAgent ? `Chat with ${currentAgent.name}` : "New Conversation"}
</h3>
<p className="text-neutral-500 text-sm max-w-md">
Type your message below to start the conversation. This chat will help you interact with the agent and explore its capabilities.
</p>
</div>
) : (
<div className="space-y-4 w-full max-w-full">
{messages.map((message) => {
const messageContent = getMessageText(message);
const agentColor = getAgentColor(message.author);
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessageComponent
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
sessionId={selectedSession as string}
/>
);
})}
{isSending && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[80%]">
<Avatar
className="bg-gradient-to-br from-purple-600 to-purple-800 shadow-md border-0"
>
<AvatarFallback className="bg-transparent">
<Bot className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
<div className="rounded-lg p-3 bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 shadow-md">
<div className="flex space-x-2">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.2s]"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
<div className="px-4 pt-4 pb-6 border-t border-[#333] bg-neutral-900 shadow-inner">
{isSending && !isLoading && (
<div className="px-4 py-2 mb-3 rounded-lg bg-neutral-800/50 border border-neutral-700/30 text-sm text-neutral-400 flex items-center shadow-sm">
<div className="mr-2 relative">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
<div className="absolute inset-0 blur-sm bg-emerald-400/20 rounded-full animate-pulse"></div>
</div>
Agent is thinking...
</div>
)}
<div className="rounded-lg shadow-md bg-neutral-800/20 border border-neutral-700/30 p-1">
<ChatInput
onSendMessage={handleSendMessageWithFiles}
isLoading={isSending || isLoading}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 rounded-full bg-emerald-500/20 flex items-center justify-center shadow-lg mb-6 border border-emerald-500/30">
<MessageSquare className="h-10 w-10 text-emerald-400" />
</div>
<h2 className="text-2xl font-semibold text-white mb-3">
Select a conversation
</h2>
<p className="text-neutral-400 mb-8 max-w-md">
Choose an existing conversation or start a new one.
</p>
<Button
onClick={() => setIsNewChatDialogOpen(true)}
className="bg-emerald-500 text-white hover:bg-emerald-600 px-6 py-6 h-auto shadow-md rounded-xl"
>
<Plus className="mr-2 h-5 w-5" />
New Conversation
</Button>
</div>
)}
</div>
<Dialog open={isNewChatDialogOpen} onOpenChange={setIsNewChatDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white shadow-xl">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="p-1.5 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<DialogTitle className="text-xl font-medium text-white">
New Conversation
</DialogTitle>
</div>
<DialogDescription className="text-neutral-400">
Select an agent to start a new conversation.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-500">
<Search className="h-4 w-4" />
</div>
<Input
placeholder="Search agents..."
className="pl-10 bg-neutral-800/40 border-neutral-700/50 text-white focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 shadow-inner rounded-xl"
value={agentSearchTerm}
onChange={(e) => setAgentSearchTerm(e.target.value)}
/>
{agentSearchTerm && (
<button
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-emerald-500 transition-colors"
onClick={() => setAgentSearchTerm("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="text-sm text-neutral-300 mb-2">Choose an agent:</div>
<ScrollArea className="h-[300px] pr-2">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8">
<div className="relative">
<Loader2 className="h-8 w-8 text-emerald-400 animate-spin" />
<div className="absolute inset-0 rounded-full blur-md bg-emerald-400/20 animate-pulse"></div>
</div>
<p className="text-neutral-400 mt-4">Loading agents...</p>
</div>
) : filteredAgents.length > 0 ? (
<div className="space-y-2">
{filteredAgents.map((agent) => (
<div
key={agent.id}
className="p-3 rounded-md cursor-pointer transition-all bg-neutral-800 hover:bg-neutral-800/90 border border-neutral-700/30 hover:border-emerald-500/30 shadow-sm hover:shadow-md group"
onClick={() => selectAgent(agent.id)}
>
<div className="flex items-center gap-2 mb-1">
<div className="p-1 rounded-full bg-emerald-500/20 group-hover:bg-emerald-500/30 transition-colors">
<MessageSquare size={14} className="text-emerald-400" />
</div>
<span className="font-medium text-white group-hover:text-emerald-50">
{agent.name}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<Badge className="text-xs bg-neutral-800/60 text-emerald-400 border border-emerald-500/30">
{agent.type}
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400">
{agent.model}
</span>
)}
</div>
{agent.description && (
<p className="text-xs text-neutral-300 mt-2 line-clamp-2 group-hover:text-neutral-200">
{agent.description}
</p>
)}
</div>
))}
</div>
) : agentSearchTerm ? (
<div className="text-center py-4 text-neutral-400">
No agent found with "{agentSearchTerm}"
</div>
) : (
<div className="text-center py-4 text-neutral-400">
<p>No agents available</p>
<p className="text-sm mt-2">
Create agents in the Agent Management screen
</p>
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
<Button
onClick={() => setIsNewChatDialogOpen(false)}
variant="outline"
className="bg-neutral-800/40 border-neutral-700/50 text-neutral-300 hover:bg-neutral-700/50 hover:text-white hover:border-neutral-600"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white shadow-xl">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="p-1.5 rounded-full bg-red-500/20">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<DialogTitle className="text-xl font-medium text-white">
Delete Session
</DialogTitle>
</div>
<DialogDescription className="text-neutral-400">
Are you sure you want to delete this session? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
onClick={() => setIsDeleteDialogOpen(false)}
variant="outline"
className="bg-neutral-800/40 border-neutral-700/50 text-neutral-300 hover:bg-neutral-700/50 hover:text-white hover:border-neutral-600"
>
Cancel
</Button>
<Button
onClick={handleDeleteSession}
className="bg-red-600 hover:bg-red-700 text-white border-0 shadow-md"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Agent Info Dialog */}
<AgentInfoDialog
agent={currentAgent}
open={isAgentInfoDialogOpen}
onOpenChange={setIsAgentInfoDialogOpen}
onAgentUpdated={handleAgentUpdated}
/>
</div>
);
}

View File

@ -0,0 +1,52 @@
/*
@author: Davidson Gomes
@file: /app/client-layout.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client"
import type React from "react"
import { usePathname } from "next/navigation"
import Sidebar from "@/components/sidebar"
export default function ClientLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const isLoginPage = pathname === "/login"
const isVerifyEmailPage = pathname.startsWith("/security/verify-email")
const isResetPasswordPage = pathname.startsWith("/security/reset-password")
const isSharedChatPage = pathname.startsWith("/shared-chat")
if (isLoginPage || isVerifyEmailPage || isResetPasswordPage || isSharedChatPage) {
return children
}
return (
<div className="flex h-screen bg-[#121212]">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
)
}

View File

@ -0,0 +1,31 @@
/*
@author: Davidson Gomes
@file: /app/clients/loading.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
export default function Loading() {
return null
}

View File

@ -0,0 +1,462 @@
/*
@author: Davidson Gomes
@file: /app/clients/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { Plus, MoreHorizontal, Edit, Trash2, Search, Users, UserPlus } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
createClient,
listClients,
getClient,
updateClient,
deleteClient,
impersonateClient,
Client,
} from "@/services/clientService"
import { useRouter } from "next/navigation"
export default function ClientsPage() {
const { toast } = useToast()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
const [clientData, setClientData] = useState({
name: "",
email: "",
})
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(1000)
const [total, setTotal] = useState(0)
const [clients, setClients] = useState<Client[]>([])
useEffect(() => {
const fetchClients = async () => {
setIsLoading(true)
try {
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error loading clients",
description: "Unable to load clients.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchClients()
}, [page, limit])
const filteredClients = Array.isArray(clients)
? clients.filter(
(client) =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.email.toLowerCase().includes(searchQuery.toLowerCase()),
)
: []
const handleAddClient = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
if (selectedClient) {
await updateClient(selectedClient.id, clientData)
toast({
title: "Client updated",
description: `${clientData.name} was updated successfully.`,
})
} else {
await createClient({ ...clientData, password: "Password@123" })
toast({
title: "Client added",
description: `${clientData.name} was added successfully.`,
})
}
setIsDialogOpen(false)
resetForm()
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to save client. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleEditClient = async (client: Client) => {
setIsLoading(true)
try {
const res = await getClient(client.id)
setSelectedClient(res.data)
setClientData({
name: res.data.name,
email: res.data.email,
})
setIsDialogOpen(true)
} catch (error) {
toast({
title: "Error searching client",
description: "Unable to search client.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const confirmDeleteClient = async () => {
if (!selectedClient) return
setIsLoading(true)
try {
await deleteClient(selectedClient.id)
toast({
title: "Client deleted",
description: `${selectedClient.name} was deleted successfully.`,
})
setIsDeleteDialogOpen(false)
setSelectedClient(null)
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to delete client. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleImpersonateClient = async (client: Client) => {
setIsLoading(true)
try {
const response = await impersonateClient(client.id)
const currentUser = localStorage.getItem("user")
if (currentUser) {
localStorage.setItem("adminUser", currentUser)
}
const currentToken = document.cookie.match(/access_token=([^;]+)/)?.[1]
if (currentToken) {
localStorage.setItem("adminToken", currentToken)
}
localStorage.setItem("isImpersonating", "true")
localStorage.setItem("impersonatedClient", client.name)
document.cookie = `isImpersonating=true; path=/; max-age=${60 * 60 * 24 * 7}`
document.cookie = `impersonatedClient=${encodeURIComponent(client.name)}; path=/; max-age=${60 * 60 * 24 * 7}`
document.cookie = `access_token=${response.access_token}; path=/; max-age=${60 * 60 * 24 * 7}`
const userData = {
...JSON.parse(localStorage.getItem("user") || "{}"),
is_admin: false,
client_id: client.id
}
localStorage.setItem("user", JSON.stringify(userData))
document.cookie = `user=${encodeURIComponent(JSON.stringify(userData))}; path=/; max-age=${60 * 60 * 24 * 7}`
toast({
title: "Impersonation mode activated",
description: `You are viewing as ${client.name}`,
})
router.push("/agents")
} catch (error) {
console.error("Error impersonating client:", error)
toast({
title: "Error",
description: "Unable to impersonate client",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const resetForm = () => {
setClientData({
name: "",
email: "",
})
setSelectedClient(null)
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-white">Client Management</h1>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm} className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
<Plus className="mr-2 h-4 w-4" />
New Client
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333]">
<form onSubmit={handleAddClient}>
<DialogHeader>
<DialogTitle className="text-white">{selectedClient ? "Edit Client" : "New Client"}</DialogTitle>
<DialogDescription className="text-neutral-400">
{selectedClient
? "Edit the existing client information."
: "Fill in the information to create a new client."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-neutral-300">
Name
</Label>
<Input
id="name"
value={clientData.name}
onChange={(e) => setClientData({ ...clientData, name: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="Company name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
type="email"
value={clientData.email}
onChange={(e) => setClientData({ ...clientData, email: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="contact@company.com"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button type="submit" className="bg-emerald-400 text-black hover:bg-[#00cc7d]" disabled={isLoading}>
{isLoading ? "Saving..." : selectedClient ? "Save Changes" : "Add Client"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
<AlertDialogHeader>
<AlertDialogTitle>Confirm delete</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-400">
Are you sure you want to delete the client "{selectedClient?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteClient}
className="bg-red-600 text-white hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card className="bg-[#1a1a1a] border-[#333] mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white text-lg">Search Clients</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-[#222] border-[#444] text-white pl-10"
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#1a1a1a] border-[#333]">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-[#333] hover:bg-[#222]">
<TableHead className="text-neutral-300">Name</TableHead>
<TableHead className="text-neutral-300">Email</TableHead>
<TableHead className="text-neutral-300">Created At</TableHead>
<TableHead className="text-neutral-300">Users</TableHead>
<TableHead className="text-neutral-300">Agents</TableHead>
<TableHead className="text-neutral-300 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.length > 0 ? (
filteredClients.map((client) => (
<TableRow key={client.id} className="border-[#333] hover:bg-[#222]">
<TableCell className="font-medium text-white">{client.name}</TableCell>
<TableCell className="text-neutral-300">{client.email}</TableCell>
<TableCell className="text-neutral-300">
{new Date(client.created_at).toLocaleDateString("pt-BR")}
</TableCell>
<TableCell className="text-neutral-300">
<div className="flex items-center">
<Users className="h-4 w-4 mr-1 text-emerald-400" />
{client.users_count ?? 0}
</div>
</TableCell>
<TableCell className="text-neutral-300">{client.agents_count ?? 0}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 text-neutral-300 hover:bg-[#333]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-[#222] border-[#444] text-white">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[#444]" />
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333]"
onClick={() => handleEditClient(client)}
>
<Edit className="mr-2 h-4 w-4 text-emerald-400" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333]"
onClick={() => handleImpersonateClient(client)}
>
<UserPlus className="mr-2 h-4 w-4 text-emerald-400" />
Enter as client
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333] text-red-500"
onClick={() => {
setSelectedClient(client)
setIsDeleteDialogOpen(true)
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-neutral-500">
No clients found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex justify-end mt-4">
<Button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1 || isLoading}>
Previous
</Button>
<span className="mx-4 text-white">Page {page} of {Math.ceil(total / limit) || 1}</span>
<Button onClick={() => setPage((p) => p + 1)} disabled={page * limit >= total || isLoading}>
Next
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,333 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/A2AComplianceCard.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
CheckCircle2,
Clock,
Shield,
Zap,
FileText,
Settings,
Network,
AlertCircle,
ExternalLink,
ChevronDown,
ChevronUp
} from "lucide-react";
import { useState } from "react";
export function A2AComplianceCard() {
const [showCoreFeatures, setShowCoreFeatures] = useState(false);
const [showAdvancedFeatures, setShowAdvancedFeatures] = useState(false);
const implementedFeatures = [
{
name: "JSON-RPC 2.0 Protocol",
status: "implemented",
icon: CheckCircle2,
description: "Full compliance with JSON-RPC 2.0 specification"
},
{
name: "message/send Method",
status: "implemented",
icon: CheckCircle2,
description: "Standard HTTP messaging with proper request/response format"
},
{
name: "message/stream Method",
status: "implemented",
icon: CheckCircle2,
description: "Real-time streaming via Server-Sent Events (SSE)"
},
{
name: "tasks/get Method",
status: "implemented",
icon: CheckCircle2,
description: "Task status querying and monitoring"
},
{
name: "tasks/cancel Method",
status: "implemented",
icon: CheckCircle2,
description: "Task cancellation support"
},
{
name: "agent/authenticatedExtendedCard",
status: "implemented",
icon: CheckCircle2,
description: "Agent discovery and capability enumeration"
},
{
name: "File Upload Support",
status: "implemented",
icon: CheckCircle2,
description: "Base64 file encoding with proper MIME type handling"
},
{
name: "UUID v4 Message IDs",
status: "implemented",
icon: CheckCircle2,
description: "Standards-compliant unique message identification"
},
{
name: "Authentication Methods",
status: "implemented",
icon: CheckCircle2,
description: "API Key and Bearer token authentication"
},
{
name: "Task State Management",
status: "implemented",
icon: CheckCircle2,
description: "Complete task lifecycle: submitted → working → completed/failed"
},
{
name: "Artifact Handling",
status: "implemented",
icon: CheckCircle2,
description: "Complex response data with structured artifacts"
},
{
name: "CORS Compliance",
status: "implemented",
icon: CheckCircle2,
description: "Proper cross-origin resource sharing configuration"
},
{
name: "tasks/pushNotificationConfig/set",
status: "implemented",
icon: CheckCircle2,
description: "Set push notification configuration for tasks"
},
{
name: "tasks/pushNotificationConfig/get",
status: "implemented",
icon: CheckCircle2,
description: "Get push notification configuration for tasks"
},
{
name: "tasks/resubscribe",
status: "implemented",
icon: CheckCircle2,
description: "Resubscribe to task updates and notifications"
}
];
const advancedFeatures = [
{
name: "Push Notifications",
status: "implemented",
icon: CheckCircle2,
description: "A2A pushNotificationConfig methods and webhook support"
},
{
name: "Multi-turn Conversations",
status: "implemented",
icon: CheckCircle2,
description: "Context preservation via contextId field as per A2A specification"
},
{
name: "Enhanced Error Diagnostics",
status: "implemented",
icon: AlertCircle,
description: "Comprehensive A2A error analysis and troubleshooting guidance"
}
];
const implementedCount = implementedFeatures.filter(f => f.status === 'implemented').length;
const totalFeatures = implementedFeatures.length + advancedFeatures.length;
const partialCount = advancedFeatures.filter(f => f.status === 'partial').length;
const advancedImplementedCount = advancedFeatures.filter(f => f.status === 'implemented').length;
const totalImplementedCount = implementedCount + advancedImplementedCount;
const completionPercentage = Math.round(((totalImplementedCount + (partialCount * 0.5)) / totalFeatures) * 100);
const getStatusColor = (status: string) => {
switch (status) {
case 'implemented': return 'text-green-400';
case 'partial': return 'text-yellow-400';
case 'planned': return 'text-blue-400';
default: return 'text-neutral-400';
}
};
const getStatusIcon = (status: string, IconComponent: any) => {
const colorClass = getStatusColor(status);
return <IconComponent className={`h-4 w-4 ${colorClass}`} />;
};
return (
<Card className="bg-gradient-to-br from-emerald-500/5 to-blue-500/5 border-emerald-500/20 text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Network className="h-5 w-5 mr-2" />
A2A Specification Compliance
</CardTitle>
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-300">Implementation Progress</span>
<span className="text-emerald-400 font-medium">{completionPercentage}%</span>
</div>
<Progress value={completionPercentage} className="h-2" />
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => {
const shouldExpand = !showCoreFeatures || !showAdvancedFeatures;
setShowCoreFeatures(shouldExpand);
setShowAdvancedFeatures(shouldExpand);
}}
className="text-xs text-neutral-400 hover:text-white transition-colors px-2 py-1 rounded border border-neutral-600 hover:border-neutral-400"
>
{showCoreFeatures && showAdvancedFeatures ? 'Collapse All' : 'Expand All'}
</button>
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
v0.2.0 Compatible
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center">
<a
href="https://google.github.io/A2A/specification"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-blue-500/10 hover:bg-blue-500/20 px-4 py-2 rounded-lg border border-blue-500/20 transition-colors"
>
<FileText className="h-4 w-4 mr-2 text-blue-400" />
<span className="text-blue-400">View Official Specification</span>
<ExternalLink className="h-3 w-3 ml-2 text-blue-400" />
</a>
</div>
<div>
<div
className="flex items-center justify-between cursor-pointer hover:bg-[#222]/30 p-2 rounded-lg transition-colors mb-4 border border-transparent hover:border-[#333]"
onClick={() => setShowCoreFeatures(!showCoreFeatures)}
>
<h3 className="text-white font-semibold flex items-center">
<CheckCircle2 className="h-4 w-4 mr-2 text-green-400" />
Core Features
<span className="ml-2 text-green-400 text-sm">({implementedCount}/{implementedFeatures.length} implemented)</span>
</h3>
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-500">
{showCoreFeatures ? 'Hide details' : 'Show details'}
</span>
{showCoreFeatures ? (
<ChevronUp className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
) : (
<ChevronDown className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
)}
</div>
</div>
{showCoreFeatures && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{implementedFeatures.map((feature, index) => (
<div key={index} className="flex items-start space-x-3 bg-[#222]/50 p-3 rounded-lg border border-[#333]">
{getStatusIcon(feature.status, feature.icon)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{feature.name}</p>
<p className="text-xs text-neutral-400">{feature.description}</p>
</div>
</div>
))}
</div>
)}
</div>
<div>
<div
className="flex items-center justify-between cursor-pointer hover:bg-[#222]/30 p-2 rounded-lg transition-colors mb-4 border border-transparent hover:border-[#333]"
onClick={() => setShowAdvancedFeatures(!showAdvancedFeatures)}
>
<h3 className="text-white font-semibold flex items-center">
<Settings className="h-4 w-4 mr-2 text-blue-400" />
Advanced Features
<span className="ml-2 text-blue-400 text-sm">({advancedImplementedCount}/{advancedFeatures.length} implemented)</span>
</h3>
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-500">
{showAdvancedFeatures ? 'Hide details' : 'Show details'}
</span>
{showAdvancedFeatures ? (
<ChevronUp className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
) : (
<ChevronDown className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
)}
</div>
</div>
{showAdvancedFeatures && (
<div className="space-y-3">
{advancedFeatures.map((feature, index) => (
<div key={index} className="flex items-start space-x-3 bg-[#222]/50 p-3 rounded-lg border border-[#333]">
{getStatusIcon(feature.status, feature.icon)}
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-white">{feature.name}</p>
<Badge
variant="outline"
className={`text-xs ${
feature.status === 'implemented' ? 'border-green-500 text-green-400' :
feature.status === 'partial' ? 'border-yellow-500 text-yellow-400' :
'border-blue-500 text-blue-400'
}`}
>
{feature.status}
</Badge>
</div>
<p className="text-xs text-neutral-400 mt-1">{feature.description}</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Shield className="h-4 w-4 text-emerald-400 mt-0.5" />
<div className="text-sm">
<p className="text-emerald-400 font-medium"> 100% A2A v0.2.0 Compliance Achieved</p>
<p className="text-emerald-300 mt-1">
All 8 official RPC methods implemented Complete protocol data objects Full workflow support Enterprise security ready
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,78 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/CodeBlock.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import React from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface CodeBlockProps {
text: string;
language: string;
showLineNumbers?: boolean;
}
export function CodeBlock({ text, language, showLineNumbers = true }: CodeBlockProps) {
const { toast } = useToast();
const copyToClipboard = () => {
navigator.clipboard.writeText(text);
toast({
title: "Copied!",
description: "Code copied to clipboard",
});
};
return (
<div className="relative rounded-md overflow-hidden">
<SyntaxHighlighter
language={language}
style={dracula}
showLineNumbers={showLineNumbers}
wrapLines={true}
customStyle={{
margin: 0,
padding: "1rem",
borderRadius: "0.375rem",
}}
>
{text}
</SyntaxHighlighter>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333] opacity-80 hover:opacity-100"
onClick={copyToClipboard}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,317 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/CodeExamplesSection.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface CodeExamplesSectionProps {
agentUrl: string;
apiKey: string;
jsonRpcRequest: any;
curlExample: string;
fetchExample: string;
}
export function CodeExamplesSection({
agentUrl,
apiKey,
jsonRpcRequest,
curlExample,
fetchExample
}: CodeExamplesSectionProps) {
const pythonExample = `import requests
import json
def test_a2a_agent():
url = "${agentUrl || "http://localhost:8000/api/v1/a2a/your-agent-id"}"
headers = {
"Content-Type": "application/json",
"x-api-key": "${apiKey || "your-api-key"}"
}
payload = ${JSON.stringify(jsonRpcRequest, null, 2)}
response = requests.post(url, headers=headers, json=payload)
data = response.json()
print("Agent response:", data)
return data
if __name__ == "__main__":
test_a2a_agent()`;
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Code Examples</CardTitle>
<CardDescription className="text-neutral-400">
Code snippets ready to use with A2A agents
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="curl">
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="curl" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
cURL
</TabsTrigger>
<TabsTrigger value="javascript" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
JavaScript
</TabsTrigger>
<TabsTrigger value="python" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Python
</TabsTrigger>
</TabsList>
<TabsContent value="curl" className="relative">
<CodeBlock
text={curlExample}
language="bash"
/>
</TabsContent>
<TabsContent value="javascript" className="relative">
<CodeBlock
text={fetchExample}
language="javascript"
/>
</TabsContent>
<TabsContent value="python" className="relative">
<CodeBlock
text={pythonExample}
language="python"
/>
</TabsContent>
</Tabs>
<div className="mt-8">
<h3 className="text-xl font-semibold text-white mb-3">Sending files to the agent</h3>
<p className="text-neutral-400 mb-4">
You can attach files to messages sent to the agent using the A2A protocol.
The files are encoded in base64 and incorporated into the message as parts of type &quot;file&quot;.
</p>
<div className="space-y-6">
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">Python</h4>
<CodeBlock
text={`import asyncio
import base64
import os
from uuid import uuid4
from common.client import A2ACardResolver, A2AClient
async def send_message_with_files():
# Instantiate client
card_resolver = A2ACardResolver("http://localhost:8000/api/v1/a2a/your-agent-id")
card = card_resolver.get_agent_card()
client = A2AClient(agent_card=card)
# Create session and task IDs
session_id = uuid4().hex
task_id = uuid4().hex
# Read file and encode in base64
file_path = "example.jpg"
with open(file_path, 'rb') as f:
file_content = base64.b64encode(f.read()).decode('utf-8')
file_name = os.path.basename(file_path)
# Create message with text and file
message = {
'role': 'user',
'parts': [
{
'type': 'text',
'text': 'Analyze this image for me',
},
{
'type': 'file',
'file': {
'name': file_name,
'bytes': file_content,
'mime_type': 'application/octet-stream' # Important: include the mime_type for correct file processing
},
}
],
}
# Create request payload
payload = {
'id': task_id,
'sessionId': session_id,
'acceptedOutputModes': ['text'],
'message': message,
}
# Send request
task_result = await client.send_task(payload)
print(f'\\nResponse: {task_result.model_dump_json(exclude_none=True)}')
if __name__ == '__main__':
asyncio.run(send_message_with_files())`}
language="python"
/>
</div>
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">JavaScript/TypeScript</h4>
<CodeBlock
text={`// Function to convert file to base64
async function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function sendMessageWithFiles() {
// Select file (in a web application)
const fileInput = document.getElementById('fileInput');
const files = fileInput.files;
if (files.length === 0) {
console.error('No file selected');
return;
}
// Convert file to base64
const file = files[0];
const base64Data = await fileToBase64(file);
// Create session and task IDs
const sessionId = crypto.randomUUID();
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Create message with text and file
const payload = {
jsonrpc: "2.0",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Analyze this document for me",
},
{
type: "file",
file: {
name: file.name,
bytes: base64Data,
mime_type: file.type
}
}
],
},
sessionId: sessionId,
id: taskId,
},
id: callId,
};
// Send request
const response = await fetch('http://localhost:8000/api/v1/a2a/your-agent-id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'your-api-key',
},
body: JSON.stringify(payload),
});
const data = await response.json();
console.log('Agent response:', data);
}`}
language="javascript"
/>
</div>
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">Curl</h4>
<CodeBlock
text={`# Convert file to base64
FILE_PATH="example.jpg"
FILE_NAME=$(basename $FILE_PATH)
BASE64_CONTENT=$(base64 -w 0 $FILE_PATH)
# Create request payload
read -r -d '' PAYLOAD << EOM
{
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [
{
"type": "text",
"text": "Analyze this image for me"
},
{
"type": "file",
"file": {
"name": "$FILE_NAME",
"bytes": "$BASE64_CONTENT",
"mime_type": "$(file --mime-type -b $FILE_PATH)"
}
}
]
},
"sessionId": "session-123",
"id": "task-456"
},
"id": "call-789"
}
EOM
# Send request
curl -X POST \\
http://localhost:8000/api/v1/a2a/your-agent-id \\
-H 'Content-Type: application/json' \\
-H 'x-api-key: your-api-key' \\
-d "$PAYLOAD"`}
language="bash"
/>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,588 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/DocumentationSection.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ClipboardCopy,
Info,
ExternalLink,
Users,
Shield,
Zap,
Network,
FileText,
MessageSquare,
Settings,
AlertCircle,
CheckCircle2,
Globe
} from "lucide-react";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface DocumentationSectionProps {
copyToClipboard: (text: string) => void;
}
export function DocumentationSection({ copyToClipboard }: DocumentationSectionProps) {
const quickStartExample = {
jsonrpc: "2.0",
id: "req-001",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Hello! Can you help me analyze this data?"
}
],
messageId: "6dbc13b5-bd57-4c2b-b503-24e381b6c8d6"
}
}
};
const streamingExample = {
jsonrpc: "2.0",
id: "req-002",
method: "message/stream",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Generate a detailed report on market trends"
}
],
messageId: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
};
const fileUploadExample = {
jsonrpc: "2.0",
id: "req-003",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Analyze this image and highlight any faces."
},
{
type: "file",
file: {
name: "input_image.png",
mimeType: "image/png",
bytes: "iVBORw0KGgoAAAANSUhEUgAAAAUA..."
}
}
],
messageId: "8f0dc03c-4c65-4a14-9b56-7e8b9f2d1a3c"
}
}
};
return (
<div className="space-y-8">
{/* Hero Section */}
<Card className="bg-gradient-to-br from-emerald-500/10 to-blue-500/10 border-emerald-500/20 text-white">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="flex items-center space-x-2 bg-emerald-500/20 px-4 py-2 rounded-full">
<Network className="h-6 w-6 text-emerald-400" />
<span className="font-bold text-emerald-400">Agent2Agent Protocol</span>
</div>
</div>
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-emerald-400 to-blue-400 bg-clip-text text-transparent">
The Standard for AI Agent Communication
</CardTitle>
<p className="text-lg text-neutral-300 mt-4 max-w-3xl mx-auto">
A2A is Google's open protocol enabling seamless communication and interoperability
between AI agents across different platforms, providers, and architectures.
</p>
</CardHeader>
<CardContent>
<div className="flex flex-wrap justify-center gap-4 mt-6">
<a
href="https://google.github.io/A2A/specification"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-emerald-500/20 hover:bg-emerald-500/30 px-4 py-2 rounded-lg transition-colors"
>
<FileText className="h-4 w-4 mr-2 text-emerald-400" />
<span className="text-emerald-400">Official Specification</span>
<ExternalLink className="h-3 w-3 ml-2 text-emerald-400" />
</a>
<a
href="https://github.com/google/A2A"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-blue-500/20 hover:bg-blue-500/30 px-4 py-2 rounded-lg transition-colors"
>
<Globe className="h-4 w-4 mr-2 text-blue-400" />
<span className="text-blue-400">GitHub Repository</span>
<ExternalLink className="h-3 w-3 ml-2 text-blue-400" />
</a>
</div>
</CardContent>
</Card>
{/* Key Features */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Zap className="h-5 w-5 mr-2" />
Key Features & Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="flex items-start space-x-3">
<div className="bg-emerald-500/20 p-2 rounded-lg">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<div>
<h3 className="font-semibold text-white">Multi-turn Conversations</h3>
<p className="text-sm text-neutral-400">Support for complex, contextual dialogues between agents</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-blue-500/20 p-2 rounded-lg">
<FileText className="h-5 w-5 text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-white">File Exchange</h3>
<p className="text-sm text-neutral-400">Upload and download files with proper MIME type handling</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-purple-500/20 p-2 rounded-lg">
<Zap className="h-5 w-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-white">Real-time Streaming</h3>
<p className="text-sm text-neutral-400">Server-Sent Events for live response streaming</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-orange-500/20 p-2 rounded-lg">
<Settings className="h-5 w-5 text-orange-400" />
</div>
<div>
<h3 className="font-semibold text-white">Task Management</h3>
<p className="text-sm text-neutral-400">Track, query, and cancel long-running tasks</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-red-500/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-red-400" />
</div>
<div>
<h3 className="font-semibold text-white">Enterprise Security</h3>
<p className="text-sm text-neutral-400">Bearer tokens, API keys, and HTTPS enforcement</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-green-500/20 p-2 rounded-lg">
<Users className="h-5 w-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-white">Agent Discovery</h3>
<p className="text-sm text-neutral-400">Standardized agent cards for capability discovery</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Protocol Methods */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Protocol Methods</CardTitle>
<p className="text-neutral-400">A2A supports multiple RPC methods for different interaction patterns</p>
</CardHeader>
<CardContent>
<Tabs defaultValue="messaging" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-[#222] border-[#444]">
<TabsTrigger value="messaging" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Messaging
</TabsTrigger>
<TabsTrigger value="tasks" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Task Management
</TabsTrigger>
<TabsTrigger value="discovery" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Discovery
</TabsTrigger>
</TabsList>
<TabsContent value="messaging" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-emerald-500 text-emerald-400">message/send</Badge>
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
</div>
<h4 className="font-semibold text-white mb-2">Standard HTTP Request</h4>
<p className="text-sm text-neutral-400 mb-3">
Send a message and receive a complete response after processing is finished.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Single request/response cycle</li>
<li> Best for simple queries</li>
<li> Lower complexity implementation</li>
<li> Synchronous operation</li>
</ul>
</div>
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-blue-500 text-blue-400">message/stream</Badge>
<Zap className="h-4 w-4 text-blue-400" />
</div>
<h4 className="font-semibold text-white mb-2">Real-time Streaming</h4>
<p className="text-sm text-neutral-400 mb-3">
Receive partial responses in real-time via Server-Sent Events.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Progressive response delivery</li>
<li> Better UX for long tasks</li>
<li> Live status updates</li>
<li> Asynchronous operation</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-purple-500 text-purple-400">tasks/get</Badge>
<Settings className="h-4 w-4 text-purple-400" />
</div>
<h4 className="font-semibold text-white mb-2">Query Task Status</h4>
<p className="text-sm text-neutral-400 mb-3">
Check the status, progress, and results of a specific task.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Real-time status checking</li>
<li> Progress monitoring</li>
<li> Result retrieval</li>
<li> Error diagnosis</li>
</ul>
</div>
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-red-500 text-red-400">tasks/cancel</Badge>
<AlertCircle className="h-4 w-4 text-red-400" />
</div>
<h4 className="font-semibold text-white mb-2">Cancel Task</h4>
<p className="text-sm text-neutral-400 mb-3">
Terminate a running task before completion.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Graceful task termination</li>
<li> Resource cleanup</li>
<li> Cost optimization</li>
<li> User control</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="discovery" className="space-y-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-green-500 text-green-400">agent/authenticatedExtendedCard</Badge>
<Users className="h-4 w-4 text-green-400" />
</div>
<h4 className="font-semibold text-white mb-2">Agent Discovery</h4>
<p className="text-sm text-neutral-400 mb-3">
Retrieve detailed information about agent capabilities, skills, and requirements.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Agent capability discovery</li>
<li> Skill and tool enumeration</li>
<li> Authentication requirements</li>
<li> API version compatibility</li>
</ul>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Code Examples */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Quick Start Examples</CardTitle>
<p className="text-neutral-400">Ready-to-use JSON-RPC examples based on the official A2A specification</p>
</CardHeader>
<CardContent>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-[#222] border-[#444]">
<TabsTrigger value="basic" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Basic Message
</TabsTrigger>
<TabsTrigger value="streaming" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Streaming
</TabsTrigger>
<TabsTrigger value="files" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
File Upload
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(quickStartExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(quickStartExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-400 mt-0.5" />
<div className="text-sm">
<p className="text-blue-400 font-medium">Key Points:</p>
<ul className="text-blue-300 mt-1 space-y-1">
<li> Uses <code className="bg-blue-500/20 px-1 rounded">message/send</code> for standard HTTP requests</li>
<li> <code className="bg-blue-500/20 px-1 rounded">messageId</code> must be a valid UUID v4</li>
<li> Response contains task ID, status, and artifacts</li>
</ul>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="streaming" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(streamingExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(streamingExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Zap className="h-4 w-4 text-purple-400 mt-0.5" />
<div className="text-sm">
<p className="text-purple-400 font-medium">Streaming Features:</p>
<ul className="text-purple-300 mt-1 space-y-1">
<li> Real-time Server-Sent Events (SSE)</li>
<li> Progressive content delivery</li>
<li> Status updates: submitted working completed</li>
</ul>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="files" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(fileUploadExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(fileUploadExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<FileText className="h-4 w-4 text-green-400 mt-0.5" />
<div className="text-sm">
<p className="text-green-400 font-medium">File Handling:</p>
<ul className="text-green-300 mt-1 space-y-1">
<li> Support for multiple file types (images, documents, etc.)</li>
<li> Base64 encoding for binary data</li>
<li> Proper MIME type specification</li>
</ul>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Security & Best Practices */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Shield className="h-5 w-5 mr-2" />
Security & Best Practices
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-white font-semibold mb-3 flex items-center">
<Shield className="h-4 w-4 mr-2 text-emerald-400" />
Authentication Methods
</h3>
<div className="space-y-3">
<div className="bg-[#222] p-3 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-2">
<code className="text-emerald-400 text-sm">x-api-key</code>
<Badge variant="outline" className="text-xs">Recommended</Badge>
</div>
<p className="text-xs text-neutral-400">Custom header for API key authentication</p>
</div>
<div className="bg-[#222] p-3 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-2">
<code className="text-blue-400 text-sm">Authorization: Bearer</code>
<Badge variant="outline" className="text-xs">Standard</Badge>
</div>
<p className="text-xs text-neutral-400">OAuth 2.0 Bearer token authentication</p>
</div>
</div>
</div>
<div>
<h3 className="text-white font-semibold mb-3 flex items-center">
<AlertCircle className="h-4 w-4 mr-2 text-orange-400" />
Security Requirements
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">HTTPS/TLS encryption required</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Input validation on all parameters</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Rate limiting and resource controls</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Proper CORS configuration</span>
</div>
</div>
</div>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5" />
<div className="text-sm">
<p className="text-amber-400 font-medium">Important:</p>
<p className="text-amber-300 mt-1">
Always obtain API credentials out-of-band. Never include sensitive authentication
data in client-side code or version control systems.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* A2A vs MCP */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Network className="h-5 w-5 mr-2" />
A2A vs Model Context Protocol (MCP)
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#222] border-b border-[#444]">
<th className="p-4 text-left text-neutral-300">Aspect</th>
<th className="p-4 text-left text-emerald-400">Agent2Agent (A2A)</th>
<th className="p-4 text-left text-blue-400">Model Context Protocol (MCP)</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Purpose</td>
<td className="p-4 text-neutral-300">Agent-to-agent communication</td>
<td className="p-4 text-neutral-300">Model-to-tool/resource integration</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Use Case</td>
<td className="p-4 text-neutral-300">AI agents collaborating as peers</td>
<td className="p-4 text-neutral-300">AI models accessing external capabilities</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Relationship</td>
<td className="p-4 text-neutral-300">Partner/delegate work</td>
<td className="p-4 text-neutral-300">Use specific capabilities</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Integration</td>
<td className="p-4 text-neutral-300 text-emerald-400"> Can use MCP internally</td>
<td className="p-4 text-neutral-300 text-blue-400"> Complements A2A</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4 bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-blue-300 text-sm">
<strong>Working Together:</strong> An A2A client agent might request an A2A server agent to perform a complex task.
The server agent, in turn, might use MCP to interact with tools, APIs, or data sources necessary to fulfill the A2A task.
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,796 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/FrontendImplementationSection.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface FrontendImplementationSectionProps {
copyToClipboard: (text: string) => void;
}
export function FrontendImplementationSection({ copyToClipboard }: FrontendImplementationSectionProps) {
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Frontend implementation</CardTitle>
<CardDescription className="text-neutral-400">
Practical examples for implementation in React applications
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs defaultValue="standard">
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="standard" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Standard HTTP
</TabsTrigger>
<TabsTrigger value="streaming" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Streaming SSE
</TabsTrigger>
<TabsTrigger value="react-component" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
React component
</TabsTrigger>
</TabsList>
<TabsContent value="standard">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Implementation of message/send</h3>
<p className="text-neutral-300 mb-4">
Example of standard implementation in JavaScript/React:
</p>
<div className="relative">
<CodeBlock
text={`async function sendTask(agentId, message) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Prepare request data
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/send",
params: {
id: taskId,
sessionId: "session-" + Math.random().toString(36).substring(2, 9),
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Indicate loading state
setIsLoading(true);
// Send the request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Process the response
const data = await response.json();
// Check for errors
if (data.error) {
console.error('Error in response:', data.error);
return null;
}
// Extract the agent response
const task = data.result;
// Show response in UI
if (task.status.message && task.status.message.parts) {
const responseText = task.status.message.parts
.filter(part => part.type === 'text')
.map(part => part.text)
.join('');
// Here you would update your React state
// setResponse(responseText);
return responseText;
}
return task;
} catch (error) {
console.error('Error sending task:', error);
return null;
} finally {
// Finalize loading state
setIsLoading(false);
}
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`async function sendTask(agentId, message) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Prepare request data
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/send",
params: {
id: taskId,
sessionId: "session-" + Math.random().toString(36).substring(2, 9),
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Indicate loading state
setIsLoading(true);
// Send the request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Process the response
const data = await response.json();
// Check for errors
if (data.error) {
console.error('Error in response:', data.error);
return null;
}
// Extract the agent response
const task = data.result;
// Show response in UI
if (task.status.message && task.status.message.parts) {
const responseText = task.status.message.parts
.filter(part => part.type === 'text')
.map(part => part.text)
.join('');
// Here you would update your React state
// setResponse(responseText);
return responseText;
}
return task;
} catch (error) {
console.error('Error sending task:', error);
return null;
} finally {
// Finalize loading state
setIsLoading(false);
}
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="streaming">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Implementation of message/stream (Streaming)</h3>
<p className="text-neutral-300 mb-4">
Example of implementation of streaming with Server-Sent Events (SSE):
</p>
<div className="relative">
<CodeBlock
text={`async function initAgentStream(agentId, message, onUpdateCallback) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
const sessionId = "session-" + Math.random().toString(36).substring(2, 9);
// Prepare request data for streaming
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/stream",
params: {
id: taskId,
sessionId: sessionId,
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Start initial POST request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE',
'Accept': 'text/event-stream' // Important for SSE
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Check content type of the response
const contentType = response.headers.get('content-type');
// If the response is already SSE, use EventSource
if (contentType?.includes('text/event-stream')) {
// Use EventSource to process the stream
setupEventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=YOUR_API_KEY_HERE\`);
return;
}
// Function to configure EventSource
function setupEventSource(url) {
const eventSource = new EventSource(url);
// Handler for received messages
eventSource.onmessage = (event) => {
try {
// Process data from the event
const data = JSON.parse(event.data);
// Process the event
if (data.result) {
// Process status if available
if (data.result.status) {
const status = data.result.status;
// Extract text if available
let currentText = '';
if (status.message && status.message.parts) {
const parts = status.message.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
currentText = parts.map(part => part.text).join('');
}
}
// Call callback with updates
onUpdateCallback({
text: currentText,
state: status.state,
complete: data.result.final === true
});
// If it's the final event, close the connection
if (data.result.final === true) {
eventSource.close();
onUpdateCallback({
complete: true,
state: status.state
});
}
}
// Process artifact if available
if (data.result.artifact) {
const artifact = data.result.artifact;
if (artifact.parts && artifact.parts.length > 0) {
const parts = artifact.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
const artifactText = parts.map(part => part.text).join('');
// Call callback with artifact
onUpdateCallback({
text: artifactText,
state: 'artifact',
complete: artifact.lastChunk === true
});
// If it's the last chunk, close the connection
if (artifact.lastChunk === true) {
eventSource.close();
}
}
}
}
}
} catch (error) {
console.error('Error processing event:', error);
onUpdateCallback({ error: error.message });
}
};
// Handler for errors
eventSource.onerror = (error) => {
console.error('Error in EventSource:', error);
eventSource.close();
onUpdateCallback({
error: 'Connection with server interrupted',
complete: true,
state: 'error'
});
};
}
} catch (error) {
console.error('Error in streaming:', error);
// Notify error through callback
onUpdateCallback({
error: error.message,
complete: true,
state: 'error'
});
return null;
}
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`async function initAgentStream(agentId, message, onUpdateCallback) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
const sessionId = "session-" + Math.random().toString(36).substring(2, 9);
// Prepare request data for streaming
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/stream",
params: {
id: taskId,
sessionId: sessionId,
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Start initial POST request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE',
'Accept': 'text/event-stream' // Important for SSE
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Check content type of the response
const contentType = response.headers.get('content-type');
// If the response is already SSE, use EventSource
if (contentType?.includes('text/event-stream')) {
// Use EventSource to process the stream
setupEventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=YOUR_API_KEY_HERE\`);
return;
}
// Function to configure EventSource
function setupEventSource(url) {
const eventSource = new EventSource(url);
// Handler for received messages
eventSource.onmessage = (event) => {
try {
// Process data from the event
const data = JSON.parse(event.data);
// Process the event
if (data.result) {
// Process status if available
if (data.result.status) {
const status = data.result.status;
// Extract text if available
let currentText = '';
if (status.message && status.message.parts) {
const parts = status.message.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
currentText = parts.map(part => part.text).join('');
}
}
// Call callback with updates
onUpdateCallback({
text: currentText,
state: status.state,
complete: data.result.final === true
});
// If it's the final event, close the connection
if (data.result.final === true) {
eventSource.close();
onUpdateCallback({
complete: true,
state: status.state
});
}
}
// Process artifact if available
if (data.result.artifact) {
const artifact = data.result.artifact;
if (artifact.parts && artifact.parts.length > 0) {
const parts = artifact.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
const artifactText = parts.map(part => part.text).join('');
// Call callback with artifact
onUpdateCallback({
text: artifactText,
state: 'artifact',
complete: artifact.lastChunk === true
});
// If it's the last chunk, close the connection
if (artifact.lastChunk === true) {
eventSource.close();
}
}
}
}
}
} catch (error) {
console.error('Error processing event:', error);
onUpdateCallback({ error: error.message });
}
};
// Handler for errors
eventSource.onerror = (error) => {
console.error('Error in EventSource:', error);
eventSource.close();
onUpdateCallback({
error: 'Connection with server interrupted',
complete: true,
state: 'error'
});
};
}
} catch (error) {
console.error('Error in streaming:', error);
// Notify error through callback
onUpdateCallback({
error: error.message,
complete: true,
state: 'error'
});
return null;
}
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="react-component">
<div className="mt-4">
<h4 className="font-medium text-emerald-400 mb-2">React component with streaming support:</h4>
<div className="relative">
<CodeBlock
text={`import React, { useState, useRef } from 'react';
function ChatComponentA2A() {
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [status, setStatus] = useState('');
// Reference to the agentId
const agentId = 'your-agent-id';
// Callback for streaming updates
const handleStreamUpdate = (update) => {
if (update.error) {
// Handle error
setStatus('error');
return;
}
// Update text
setResponse(update.text);
// Update status
setStatus(update.state);
// If it's complete, finish streaming
if (update.complete) {
setIsStreaming(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!message.trim()) return;
// Clear previous response
setResponse('');
setStatus('submitted');
// Start streaming
setIsStreaming(true);
try {
// Start stream with the agent
await initAgentStream(agentId, message, handleStreamUpdate);
// Clear message field after sending
setMessage('');
} catch (error) {
console.error('Error:', error);
setStatus('error');
setIsStreaming(false);
}
};
// Render status indicator based on status
const renderStatusIndicator = () => {
switch (status) {
case 'submitted':
return <span className="badge badge-info">Sent</span>;
case 'working':
return <span className="badge badge-warning">Processing</span>;
case 'completed':
return <span className="badge badge-success">Completed</span>;
case 'error':
return <span className="badge badge-danger">Error</span>;
default:
return null;
}
};
return (
<div className="chat-container">
<div className="chat-messages">
{response && (
<div className="message agent-message">
<div className="message-header">
<div className="agent-name">A2A Agent</div>
{renderStatusIndicator()}
</div>
<div className="message-content">
{response}
{status === 'working' && !response && (
<div className="typing-indicator">
<span></span><span></span><span></span>
</div>
)}
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isStreaming}
className="chat-input"
/>
<button
type="submit"
disabled={isStreaming || !message.trim()}
className="send-button"
>
{isStreaming ? 'Processing...' : 'Send'}
</button>
</form>
</div>
);
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`import React, { useState, useRef } from 'react';
function ChatComponentA2A() {
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [status, setStatus] = useState('');
// Reference to the agentId
const agentId = 'your-agent-id';
// Callback for streaming updates
const handleStreamUpdate = (update) => {
if (update.error) {
// Handle error
setStatus('error');
return;
}
// Update text
setResponse(update.text);
// Update status
setStatus(update.state);
// If it's complete, finish streaming
if (update.complete) {
setIsStreaming(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!message.trim()) return;
// Clear previous response
setResponse('');
setStatus('submitted');
// Start streaming
setIsStreaming(true);
try {
// Start stream with the agent
await initAgentStream(agentId, message, handleStreamUpdate);
// Clear message field after sending
setMessage('');
} catch (error) {
console.error('Error:', error);
setStatus('error');
setIsStreaming(false);
}
};
// Render status indicator based on status
const renderStatusIndicator = () => {
switch (status) {
case 'submitted':
return <span className="badge badge-info">Sent</span>;
case 'working':
return <span className="badge badge-warning">Processing</span>;
case 'completed':
return <span className="badge badge-success">Completed</span>;
case 'error':
return <span className="badge badge-danger">Error</span>;
default:
return null;
}
};
return (
<div className="chat-container">
<div className="chat-messages">
{response && (
<div className="message agent-message">
<div className="message-header">
<div className="agent-name">A2A Agent</div>
{renderStatusIndicator()}
</div>
<div className="message-content">
{response}
{status === 'working' && !response && (
<div className="typing-indicator">
<span></span><span></span><span></span>
</div>
)}
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isStreaming}
className="chat-input"
/>
<button
type="submit"
disabled={isStreaming || !message.trim()}
className="send-button"
>
{isStreaming ? 'Processing...' : 'Send'}
</button>
</form>
</div>
);
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,523 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/HttpLabForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Send, Paperclip, X, FileText, Image, File, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
name: string;
type: string;
size: number;
base64: string;
}
interface HttpLabFormProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
sendRequest: () => Promise<void>;
isLoading: boolean;
setFiles?: (files: AttachedFile[]) => void;
a2aMethod: string;
setA2aMethod: (method: string) => void;
authMethod: string;
setAuthMethod: (method: string) => void;
generateNewIds: () => void;
currentTaskId?: string | null;
conversationHistory?: any[];
clearHistory?: () => void;
webhookUrl?: string;
setWebhookUrl?: (url: string) => void;
enableWebhooks?: boolean;
setEnableWebhooks?: (enabled: boolean) => void;
showDetailedErrors?: boolean;
setShowDetailedErrors?: (show: boolean) => void;
}
export function HttpLabForm({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
sendRequest,
isLoading,
setFiles = () => {},
a2aMethod,
setA2aMethod,
authMethod,
setAuthMethod,
generateNewIds,
currentTaskId,
conversationHistory,
clearHistory,
webhookUrl,
setWebhookUrl,
enableWebhooks,
setEnableWebhooks,
showDetailedErrors,
setShowDetailedErrors
}: HttpLabFormProps) {
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const clearAttachedFiles = () => {
setAttachedFiles([]);
};
const handleSendRequest = async () => {
await sendRequest();
clearAttachedFiles();
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const maxFileSize = 5 * 1024 * 1024; // 5MB limit
const newFiles = Array.from(e.target.files);
if (attachedFiles.length + newFiles.length > 5) {
toast({
title: "File limit exceeded",
description: "You can only attach up to 5 files.",
variant: "destructive"
});
return;
}
const filesToAdd: AttachedFile[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: "File too large",
description: `The file ${file.name} exceeds the 5MB size limit.`,
variant: "destructive"
});
continue;
}
try {
const base64 = await readFileAsBase64(file);
filesToAdd.push({
name: file.name,
type: file.type,
size: file.size,
base64: base64
});
} catch (error) {
console.error("Failed to read file:", error);
toast({
title: "Failed to read file",
description: `Could not process ${file.name}.`,
variant: "destructive"
});
}
}
if (filesToAdd.length > 0) {
const updatedFiles = [...attachedFiles, ...filesToAdd];
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1]; // Remove data URL prefix
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const removeFile = (index: number) => {
const updatedFiles = attachedFiles.filter((_, i) => i !== index);
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const isImageFile = (type: string): boolean => {
return type.startsWith('image/');
};
return (
<div className="space-y-4">
{/* A2A Method and Authentication Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-[#1a1a1a] border border-[#333] rounded-md">
<div>
<label className="text-sm text-neutral-400 mb-2 block">A2A Method</label>
<Select value={a2aMethod} onValueChange={setA2aMethod}>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select A2A method" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444]">
<SelectItem value="message/send" className="text-white hover:bg-[#333]">
message/send
</SelectItem>
<SelectItem value="message/stream" className="text-white hover:bg-[#333]">
message/stream
</SelectItem>
<SelectItem value="tasks/get" className="text-white hover:bg-[#333]">
tasks/get
</SelectItem>
<SelectItem value="tasks/cancel" className="text-white hover:bg-[#333]">
tasks/cancel
</SelectItem>
<SelectItem value="tasks/pushNotificationConfig/set" className="text-white hover:bg-[#333]">
tasks/pushNotificationConfig/set
</SelectItem>
<SelectItem value="tasks/pushNotificationConfig/get" className="text-white hover:bg-[#333]">
tasks/pushNotificationConfig/get
</SelectItem>
<SelectItem value="tasks/resubscribe" className="text-white hover:bg-[#333]">
tasks/resubscribe
</SelectItem>
<SelectItem value="agent/authenticatedExtendedCard" className="text-white hover:bg-[#333]">
agent/authenticatedExtendedCard
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm text-neutral-400 mb-2 block">Authentication Method</label>
<Select value={authMethod} onValueChange={setAuthMethod}>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select auth method" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444]">
<SelectItem value="api-key" className="text-white hover:bg-[#333]">
API Key (x-api-key header)
</SelectItem>
<SelectItem value="bearer" className="text-white hover:bg-[#333]">
Bearer Token (Authorization header)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Multi-turn Conversation History Controls */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && conversationHistory && conversationHistory.length > 0 && (
<div className="p-4 bg-emerald-500/5 border border-emerald-500/20 rounded-md">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-emerald-400">
Multi-turn Conversation Active
</span>
</div>
</div>
<div className="text-xs text-emerald-300">
💬 {conversationHistory.length} messages in conversation history (contextId active)
</div>
</div>
)}
{/* Push Notifications (Webhook) Configuration */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream" || a2aMethod.startsWith("tasks/")) && (
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-md">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="enableWebhooks"
checked={enableWebhooks}
onChange={(e) => setEnableWebhooks?.(e.target.checked)}
className="rounded bg-[#222] border-[#444] text-blue-400 focus:ring-blue-400"
/>
<label htmlFor="enableWebhooks" className="text-sm font-medium text-blue-400">
Enable Push Notifications (Webhooks)
</label>
</div>
</div>
{enableWebhooks && (
<div className="mt-3">
<label className="text-sm text-neutral-400 mb-1 block">Webhook URL</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl?.(e.target.value)}
placeholder="https://your-server.com/webhook/a2a"
className="bg-[#222] border-[#444] text-white"
/>
<div className="text-xs text-blue-300 mt-1">
{a2aMethod === "tasks/pushNotificationConfig/set"
? "📡 Configure push notifications for task"
: "📡 Webhook URL for push notifications (configured via pushNotificationConfig)"
}
</div>
</div>
)}
{!enableWebhooks && (
<div className="text-xs text-neutral-400">
{a2aMethod === "tasks/pushNotificationConfig/set"
? "Push notification configuration will be set to null."
: "No push notifications will be configured for this request."
}
</div>
)}
</div>
)}
{/* Advanced Error Handling Configuration */}
<div className="p-4 bg-orange-500/5 border border-orange-500/20 rounded-md">
<div className="flex items-center space-x-2 mb-3">
<input
type="checkbox"
id="showDetailedErrors"
checked={showDetailedErrors}
onChange={(e) => setShowDetailedErrors?.(e.target.checked)}
className="rounded bg-[#222] border-[#444] text-orange-400 focus:ring-orange-400"
/>
<label htmlFor="showDetailedErrors" className="text-sm font-medium text-orange-400">
Enable Detailed Error Logging
</label>
</div>
<div className="text-xs text-neutral-400">
{showDetailedErrors
? "🔍 Detailed error information will be shown in debug logs (client-side only)."
: "⚡ Basic error handling only - minimal error information in logs."
}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Agent URL</label>
<Input
value={agentUrl}
onChange={(e) => setAgentUrl(e.target.value)}
placeholder="http://localhost:8000/api/v1/a2a/your-agent-id"
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{authMethod === "bearer" ? "Bearer Token" : "API Key"} (optional)
</label>
<Input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authMethod === "bearer" ? "Your Bearer token" : "Your API key"}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</div>
{/* Show current task ID if available */}
{currentTaskId && (
<div className="p-3 bg-[#1a1a1a] border border-emerald-400/20 rounded-md">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-neutral-400">Current Task ID:</span>
<span className="ml-2 text-emerald-400 font-mono text-sm">{currentTaskId}</span>
</div>
</div>
</div>
)}
{/* Message input - only show for message methods */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && (
<div>
<label className="text-sm text-neutral-400 mb-1 block">Message</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What is the A2A protocol?"
className="bg-[#222] border-[#444] text-white min-h-[100px]"
/>
</div>
)}
{/* File attachment - only show for message methods */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-neutral-400">
Attach Files (up to 5, max 5MB each)
</label>
<Button
variant="outline"
size="sm"
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={attachedFiles.length >= 5}
>
<Paperclip className="h-4 w-4 mr-2" />
Browse Files
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
/>
</div>
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-[#333] text-white rounded-md p-1.5 text-xs"
>
{isImageFile(file.type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[150px] truncate">{file.name}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
<Separator className="my-4 bg-[#333]" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-neutral-400">Session ID</label>
<Button
variant="ghost"
size="sm"
onClick={generateNewIds}
className="h-6 px-2 text-xs text-neutral-400 hover:text-white"
>
<RotateCcw className="h-3 w-3 mr-1" />
New IDs
</Button>
</div>
<Input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{a2aMethod.startsWith("tasks/") ? "Task ID (for operation)" : "Message ID (UUID)"}
</label>
<Input
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder={a2aMethod.startsWith("tasks/") ? "Task ID to query/cancel" : "UUID for message"}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Request ID</label>
<Input
value={callId}
onChange={(e) => setCallId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="req-123"
/>
</div>
</div>
<Button
onClick={handleSendRequest}
disabled={isLoading}
className="bg-emerald-400 text-black hover:bg-[#00cc7d] w-full mt-4"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-black mr-2"></div>
Sending...
</div>
) : (
<div className="flex items-center">
<Send className="mr-2 h-4 w-4" />
{a2aMethod === "message/send" && "Send Message"}
{a2aMethod === "message/stream" && "Start Stream"}
{a2aMethod === "tasks/get" && "Get Task Status"}
{a2aMethod === "tasks/cancel" && "Cancel Task"}
{a2aMethod === "tasks/pushNotificationConfig/set" && "Set Push Config"}
{a2aMethod === "tasks/pushNotificationConfig/get" && "Get Push Config"}
{a2aMethod === "tasks/resubscribe" && "Resubscribe to Task"}
{a2aMethod === "agent/authenticatedExtendedCard" && "Get Agent Card"}
</div>
)}
</Button>
</div>
);
}

View File

@ -0,0 +1,201 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/LabSection.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { HttpLabForm } from "@/app/documentation/components/HttpLabForm";
import { StreamLabForm } from "@/app/documentation/components/StreamLabForm";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface LabSectionProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
a2aMethod: string;
setA2aMethod: (method: string) => void;
authMethod: string;
setAuthMethod: (method: string) => void;
generateNewIds: () => void;
sendRequest: () => Promise<void>;
sendStreamRequestWithEventSource: () => Promise<void>;
isLoading: boolean;
isStreaming: boolean;
streamResponse: string;
streamStatus: string;
streamHistory: string[];
streamComplete: boolean;
response: string;
copyToClipboard: (text: string) => void;
renderStatusIndicator: () => JSX.Element | null;
renderTypingIndicator: () => JSX.Element | null;
}
export function LabSection({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
a2aMethod,
setA2aMethod,
authMethod,
setAuthMethod,
generateNewIds,
sendRequest,
sendStreamRequestWithEventSource,
isLoading,
isStreaming,
streamResponse,
streamStatus,
streamHistory,
streamComplete,
response,
copyToClipboard,
renderStatusIndicator,
renderTypingIndicator
}: LabSectionProps) {
const [labMode, setLabMode] = useState("http");
return (
<>
<Card className="bg-[#1a1a1a] border-[#333] text-white mb-6">
<CardHeader>
<CardTitle className="text-emerald-400">A2A Test Lab</CardTitle>
<CardDescription className="text-neutral-400">
Test your A2A agent with different communication methods
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="http" onValueChange={setLabMode}>
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="http" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
HTTP Request
</TabsTrigger>
<TabsTrigger value="stream" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Streaming
</TabsTrigger>
</TabsList>
<TabsContent value="http">
<HttpLabForm
agentUrl={agentUrl}
setAgentUrl={setAgentUrl}
apiKey={apiKey}
setApiKey={setApiKey}
message={message}
setMessage={setMessage}
sessionId={sessionId}
setSessionId={setSessionId}
taskId={taskId}
setTaskId={setTaskId}
callId={callId}
setCallId={setCallId}
a2aMethod={a2aMethod}
setA2aMethod={setA2aMethod}
authMethod={authMethod}
setAuthMethod={setAuthMethod}
generateNewIds={generateNewIds}
sendRequest={sendRequest}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value="stream">
<StreamLabForm
agentUrl={agentUrl}
setAgentUrl={setAgentUrl}
apiKey={apiKey}
setApiKey={setApiKey}
message={message}
setMessage={setMessage}
sessionId={sessionId}
setSessionId={setSessionId}
taskId={taskId}
setTaskId={setTaskId}
callId={callId}
setCallId={setCallId}
authMethod={authMethod}
sendStreamRequest={sendStreamRequestWithEventSource}
isStreaming={isStreaming}
streamResponse={streamResponse}
streamStatus={streamStatus}
streamHistory={streamHistory}
renderStatusIndicator={renderStatusIndicator}
renderTypingIndicator={renderTypingIndicator}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{response && labMode === "http" && (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Response</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<CodeBlock
text={response}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(response)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
</>
);
}

View File

@ -0,0 +1,179 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/QuickStartTemplates.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
MessageSquare,
FileText,
Zap,
Settings,
Users,
Play
} from "lucide-react";
interface QuickStartTemplate {
id: string;
name: string;
description: string;
icon: any;
method: string;
message: string;
useCase: string;
}
interface QuickStartTemplatesProps {
onSelectTemplate: (template: QuickStartTemplate) => void;
}
export function QuickStartTemplates({ onSelectTemplate }: QuickStartTemplatesProps) {
const templates: QuickStartTemplate[] = [
{
id: "hello",
name: "Hello Agent",
description: "Simple greeting to test agent connectivity",
icon: MessageSquare,
method: "message/send",
message: "Hello! Can you introduce yourself and tell me what you can do?",
useCase: "Basic connectivity test"
},
{
id: "analysis",
name: "Data Analysis",
description: "Request data analysis and insights",
icon: FileText,
method: "message/send",
message: "Please analyze the current market trends in AI technology and provide key insights with recommendations.",
useCase: "Complex analytical tasks"
},
{
id: "streaming",
name: "Long Content",
description: "Generate lengthy content with streaming",
icon: Zap,
method: "message/stream",
message: "Write a comprehensive guide about implementing the Agent2Agent protocol, including technical details, best practices, and code examples.",
useCase: "Streaming responses"
},
{
id: "task-query",
name: "Task Status",
description: "Query the status of a running task",
icon: Settings,
method: "tasks/get",
message: "",
useCase: "Task management"
},
{
id: "capabilities",
name: "Agent Capabilities",
description: "Discover agent capabilities and skills",
icon: Users,
method: "agent/authenticatedExtendedCard",
message: "",
useCase: "Agent discovery"
}
];
const getMethodColor = (method: string) => {
switch (method) {
case 'message/send': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'message/stream': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'tasks/get': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'tasks/cancel': return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'agent/authenticatedExtendedCard': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
default: return 'bg-neutral-500/20 text-neutral-400 border-neutral-500/30';
}
};
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white mb-6">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Play className="h-5 w-5 mr-2" />
Quick Start Templates
</CardTitle>
<p className="text-neutral-400 text-sm">
Choose a template to quickly test different A2A protocol methods
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => {
const IconComponent = template.icon;
return (
<div
key={template.id}
className="bg-[#222] border border-[#444] rounded-lg p-4 hover:border-emerald-500/50 transition-colors cursor-pointer group"
onClick={() => onSelectTemplate(template)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
<div className="bg-emerald-500/20 p-2 rounded-lg">
<IconComponent className="h-4 w-4 text-emerald-400" />
</div>
<div>
<h3 className="font-semibold text-white text-sm">{template.name}</h3>
<p className="text-xs text-neutral-400">{template.useCase}</p>
</div>
</div>
</div>
<p className="text-xs text-neutral-300 mb-3 line-clamp-2">
{template.description}
</p>
<div className="flex items-center justify-between">
<Badge className={`text-xs ${getMethodColor(template.method)}`}>
{template.method}
</Badge>
<Button
size="sm"
variant="ghost"
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 text-xs px-2 py-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
>
Use Template
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-blue-300 text-xs">
💡 <strong>Tip:</strong> These templates automatically configure the correct A2A method and provide example messages.
Simply select one and customize the agent URL and authentication.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,366 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/StreamLabForm.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
name: string;
type: string;
size: number;
base64: string;
}
interface StreamLabFormProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
sendStreamRequest: () => Promise<void>;
isStreaming: boolean;
streamResponse: string;
streamStatus: string;
streamHistory: string[];
renderStatusIndicator: () => JSX.Element | null;
renderTypingIndicator: () => JSX.Element | null;
setFiles?: (files: AttachedFile[]) => void;
authMethod: string;
currentTaskId?: string | null;
}
export function StreamLabForm({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
sendStreamRequest,
isStreaming,
streamResponse,
streamStatus,
streamHistory,
renderStatusIndicator,
renderTypingIndicator,
setFiles = () => {},
authMethod,
currentTaskId
}: StreamLabFormProps) {
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const clearAttachedFiles = () => {
setAttachedFiles([]);
};
const handleSendStreamRequest = async () => {
await sendStreamRequest();
clearAttachedFiles();
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const maxFileSize = 5 * 1024 * 1024; // 5MB limit
const newFiles = Array.from(e.target.files);
if (attachedFiles.length + newFiles.length > 5) {
toast({
title: "File limit exceeded",
description: "You can only attach up to 5 files.",
variant: "destructive"
});
return;
}
const filesToAdd: AttachedFile[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: "File too large",
description: `The file ${file.name} exceeds the 5MB size limit.`,
variant: "destructive"
});
continue;
}
try {
const base64 = await readFileAsBase64(file);
filesToAdd.push({
name: file.name,
type: file.type,
size: file.size,
base64: base64
});
} catch (error) {
console.error("Failed to read file:", error);
toast({
title: "Failed to read file",
description: `Could not process ${file.name}.`,
variant: "destructive"
});
}
}
if (filesToAdd.length > 0) {
const updatedFiles = [...attachedFiles, ...filesToAdd];
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1]; // Remove data URL prefix
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const removeFile = (index: number) => {
const updatedFiles = attachedFiles.filter((_, i) => i !== index);
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const isImageFile = (type: string): boolean => {
return type.startsWith('image/');
};
return (
<div className="space-y-4">
{/* A2A Streaming Information */}
<div className="p-4 bg-[#1a1a1a] border border-[#333] rounded-md">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-emerald-400">A2A Streaming Mode</span>
<span className="text-xs text-neutral-400">Method: message/stream</span>
</div>
<div className="text-xs text-neutral-400">
Authentication: {authMethod === "bearer" ? "Bearer Token" : "API Key"} header
</div>
{currentTaskId && (
<div className="mt-2 pt-2 border-t border-[#333]">
<span className="text-xs text-neutral-400">Current Task ID: </span>
<span className="text-xs text-emerald-400 font-mono">{currentTaskId}</span>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Agent URL</label>
<Input
value={agentUrl}
onChange={(e) => setAgentUrl(e.target.value)}
placeholder="http://localhost:8000/api/v1/a2a/your-agent-id"
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{authMethod === "bearer" ? "Bearer Token" : "API Key"} (optional)
</label>
<Input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authMethod === "bearer" ? "Your Bearer token" : "Your API key"}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Message</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What is the A2A protocol?"
className="bg-[#222] border-[#444] text-white min-h-[100px]"
disabled={isStreaming}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-neutral-400">
Attach Files (up to 5, max 5MB each)
</label>
<Button
variant="outline"
size="sm"
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={attachedFiles.length >= 5 || isStreaming}
>
<Paperclip className="h-4 w-4 mr-2" />
Browse Files
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
disabled={isStreaming}
/>
</div>
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-[#333] text-white rounded-md p-1.5 text-xs"
>
{isImageFile(file.type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[150px] truncate">{file.name}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
disabled={isStreaming}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<Separator className="my-4 bg-[#333]" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Session ID</label>
<Input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Task ID</label>
<Input
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Call ID</label>
<Input
value={callId}
onChange={(e) => setCallId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
</div>
<Button
onClick={handleSendStreamRequest}
disabled={isStreaming}
className="bg-emerald-400 text-black hover:bg-[#00cc7d] w-full mt-4"
>
{isStreaming ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-black mr-2"></div>
Streaming...
</div>
) : (
<div className="flex items-center">
<Send className="mr-2 h-4 w-4" />
Start Streaming
</div>
)}
</Button>
{streamResponse && (
<div className="mt-6 rounded-md bg-[#222] border border-[#333] p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-medium text-white">Response</h3>
{renderStatusIndicator && renderStatusIndicator()}
</div>
<div className="whitespace-pre-wrap text-sm font-mono text-neutral-300">
{streamResponse}
</div>
{renderTypingIndicator && renderTypingIndicator()}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,470 @@
/*
@author: Davidson Gomes
@file: /app/documentation/components/TechnicalDetailsSection.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface TechnicalDetailsSectionProps {
copyToClipboard: (text: string) => void;
}
export function TechnicalDetailsSection({ copyToClipboard }: TechnicalDetailsSectionProps) {
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Technical Details of the Methods</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Method message/send</h3>
<p className="text-neutral-300 mb-4">
The <code className="bg-[#333] px-1 rounded">message/send</code> method performs a standard HTTP request and waits for the complete response.
</p>
<div className="space-y-4">
<div>
<h4 className="font-medium text-white mb-2">Request:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/send",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/send",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Headers:</h4>
<div className="relative">
<CodeBlock
text={`Content-Type: application/json
x-api-key: YOUR_API_KEY`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`Content-Type: application/json
x-api-key: YOUR_API_KEY`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Response:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
result: {
status: {
state: "completed",
message: {
role: "model",
parts: [
{
type: "text",
text: "Complete agent response here."
}
]
}
}
},
id: "call-123"
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
result: {
status: {
state: "completed",
message: {
role: "model",
parts: [
{
type: "text",
text: "Complete agent response here."
}
]
}
}
},
id: "call-123"
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<Separator className="my-6 bg-[#333]" />
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Method message/stream</h3>
<p className="text-neutral-300 mb-4">
The <code className="bg-[#333] px-1 rounded">message/stream</code> method uses Server-Sent Events (SSE) to receive real-time updates.
</p>
<div className="space-y-4">
<div>
<h4 className="font-medium text-white mb-2">Request:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/stream",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/stream",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Headers:</h4>
<div className="relative">
<CodeBlock
text={`Content-Type: application/json
x-api-key: YOUR_API_KEY
Accept: text/event-stream`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`Content-Type: application/json
x-api-key: YOUR_API_KEY
Accept: text/event-stream`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">SSE Event Format:</h4>
<p className="text-neutral-300 mb-4">
Each event follows the standard Server-Sent Events (SSE) format, with the "data:" prefix followed by the JSON content and terminated by two newlines ("\n\n"):
</p>
<div className="relative">
<CodeBlock
text={`data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"working","message":{"role":"agent","parts":[{"type":"text","text":"Processing..."}]},"timestamp":"2025-05-13T18:10:37.219Z"},"final":false}}
data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"completed","timestamp":"2025-05-13T18:10:40.456Z"},"final":true}}
`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"working","message":{"role":"agent","parts":[{"type":"text","text":"Processing..."}]},"timestamp":"2025-05-13T18:10:37.219Z"},"final":false}}
data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"completed","timestamp":"2025-05-13T18:10:40.456Z"},"final":true}}
`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Event Types:</h4>
<ul className="list-disc list-inside text-neutral-300 space-y-2 mb-4">
<li><span className="text-emerald-400">Status Updates</span>: Contains the <code className="bg-[#333] px-1 rounded">status</code> field with information about the task status.</li>
<li><span className="text-emerald-400">Artifact Updates</span>: Contains the <code className="bg-[#333] px-1 rounded">artifact</code> field with the content generated by the agent.</li>
<li><span className="text-emerald-400">Ping Events</span>: Simple events with the format <code className="bg-[#333] px-1 rounded">: ping</code> to keep the connection active.</li>
</ul>
</div>
<div>
<h4 className="font-medium text-white mb-2">Client Consumption:</h4>
<p className="text-neutral-300 mb-2">
For a better experience, we recommend using the <code className="bg-[#333] px-1 rounded">EventSource</code> API to consume the events:
</p>
<div className="relative">
<CodeBlock
text={`// After receiving the initial response via POST, use EventSource to stream
const eventSource = new EventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=\${apiKey}\`);
// Process the received events
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Process different types of events
if (data.result) {
// 1. Process status updates
if (data.result.status) {
const state = data.result.status.state; // "working", "completed", etc.
// Check if there is a text message
if (data.result.status.message?.parts) {
const textParts = data.result.status.message.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the text
updateUI(textParts);
}
// Check if it is the final event
if (data.result.final === true) {
eventSource.close(); // Close connection
}
}
// 2. Process the generated artifacts
if (data.result.artifact) {
const artifact = data.result.artifact;
// Extract text from the artifact
if (artifact.parts) {
const artifactText = artifact.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the artifact
updateArtifactUI(artifactText);
}
}
}
};
// Handle errors
eventSource.onerror = (error) => {
console.error("Error in SSE:", error);
eventSource.close();
};`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`// After receiving the initial response via POST, use EventSource to stream
const eventSource = new EventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=\${apiKey}\`);
// Process the received events
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Process different types of events
if (data.result) {
// 1. Process status updates
if (data.result.status) {
const state = data.result.status.state; // "working", "completed", etc.
// Check if there is a text message
if (data.result.status.message?.parts) {
const textParts = data.result.status.message.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the text
updateUI(textParts);
}
// Check if it is the final event
if (data.result.final === true) {
eventSource.close(); // Close connection
}
}
// 2. Process the generated artifacts
if (data.result.artifact) {
const artifact = data.result.artifact;
// Extract text from the artifact
if (artifact.parts) {
const artifactText = artifact.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the artifact
updateArtifactUI(artifactText);
}
}
}
};
// Handle errors
eventSource.onerror = (error) => {
console.error("Error in SSE:", error);
eventSource.close();
};`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Possible task states:</h4>
<ul className="list-disc list-inside text-neutral-300 space-y-1">
<li><span className="text-emerald-400">submitted</span>: Task sent but not yet processed</li>
<li><span className="text-emerald-400">working</span>: Task being processed by the agent</li>
<li><span className="text-emerald-400">completed</span>: Task completed successfully</li>
<li><span className="text-emerald-400">input-required</span>: Agent waiting for additional user input</li>
<li><span className="text-emerald-400">failed</span>: Task failed during processing</li>
<li><span className="text-emerald-400">canceled</span>: Task was canceled</li>
</ul>
</div>
</div>
</div>
<div className="bg-[#222] p-4 rounded-md border border-[#444]">
<h4 className="font-medium text-white mb-2">Possible task states:</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
<li className="flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>submitted</strong>: Task sent</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>working</strong>: Task being processed</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>input-required</strong>: Agent waiting for additional user input</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>completed</strong>: Task completed successfully</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-neutral-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>canceled</strong>: Task canceled</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>failed</strong>: Task processing failed</span>
</li>
</ul>
</div>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

87
frontend/app/globals.css Normal file
View File

@ -0,0 +1,87 @@
/*
@author: Davidson Gomes
@file: /app/globals.css
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 70.6% 45.3%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

73
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,73 @@
/*
@author: Davidson Gomes
@file: /app/layout.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
import type React from "react";
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import ClientLayout from "./client-layout";
import ImpersonationBar from "@/components/ImpersonationBar";
import { PublicEnvScript } from "next-runtime-env";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Evo AI",
description: "AI Multi-Agent Platform",
icons: {
icon: "/favicon.svg",
},
generator: "v0.dev",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<PublicEnvScript />
</head>
<body className={inter.className}>
<ImpersonationBar />
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
>
<ClientLayout>{children}</ClientLayout>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

556
frontend/app/login/page.tsx Normal file
View File

@ -0,0 +1,556 @@
/*
@author: Davidson Gomes
@file: /app/login/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client";
import type React from "react";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { login, forgotPassword, getMe, register, resendVerification } from "@/services/authService";
import { CheckCircle2, AlertCircle } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState("login");
const [showRegisterSuccess, setShowRegisterSuccess] = useState(false);
const [showForgotSuccess, setShowForgotSuccess] = useState(false);
const [redirectSeconds, setRedirectSeconds] = useState(5);
const redirectTimer = useRef<NodeJS.Timeout | null>(null);
const [loginError, setLoginError] = useState("");
const [isEmailNotVerified, setIsEmailNotVerified] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [loginData, setLoginData] = useState({
email: "",
password: "",
});
const [registerData, setRegisterData] = useState({
email: "",
password: "",
confirmPassword: "",
name: "",
});
const [forgotEmail, setForgotEmail] = useState("");
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLoginError("");
setIsEmailNotVerified(false);
try {
const response = await login({
email: loginData.email,
password: loginData.password,
});
if (response.data.access_token) {
localStorage.setItem("access_token", response.data.access_token);
document.cookie = `access_token=${
response.data.access_token
}; path=/; max-age=${60 * 60 * 24 * 7}`;
const meResponse = await getMe();
if (meResponse.data) {
localStorage.setItem("user", JSON.stringify(meResponse.data));
document.cookie = `user=${encodeURIComponent(
JSON.stringify(meResponse.data)
)}; path=/; max-age=${60 * 60 * 24 * 7}`;
}
}
router.push("/");
} catch (error: any) {
let errorDetail = "Check your credentials and try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorDetail = error.response.data.detail;
} else if (error.response.data.detail) {
errorDetail = JSON.stringify(error.response.data.detail);
}
}
if (errorDetail === "Email not verified") {
setIsEmailNotVerified(true);
}
setLoginError(errorDetail);
} finally {
setIsLoading(false);
}
};
const handleResendVerification = async () => {
if (!loginData.email) return;
setIsResendingVerification(true);
try {
await resendVerification({ email: loginData.email });
toast({
title: "Verification email sent",
description: "Please check your inbox to verify your account.",
});
} catch (error: any) {
toast({
title: "Error sending verification email",
description:
error?.response?.data?.detail ||
"Unable to send verification email. Please try again.",
variant: "destructive",
});
} finally {
setIsResendingVerification(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!registerData.password) {
toast({
title: "Password required",
description: "Please enter a password.",
variant: "destructive",
});
return;
}
if (registerData.password.length < 8) {
toast({
title: "Password too short",
description: "Password must be at least 8 characters long.",
variant: "destructive",
});
return;
}
if (registerData.password !== registerData.confirmPassword) {
toast({
title: "Passwords don't match",
description: "Please make sure your passwords match.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
await register({
email: registerData.email,
password: registerData.password,
name: registerData.name,
});
toast({
title: "Registration successful",
description: "Please check your email to verify your account.",
});
setShowRegisterSuccess(true);
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
redirectTimer.current = setInterval(() => {
setRedirectSeconds((s) => s - 1);
}, 1000);
} catch (error: any) {
let errorMessage = "Unable to register. Please try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorMessage = error.response.data.detail;
} else if (error.response.data.detail) {
errorMessage = JSON.stringify(error.response.data.detail);
}
}
toast({
title: "Error registering",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if ((showRegisterSuccess || showForgotSuccess) && redirectSeconds === 0) {
setShowRegisterSuccess(false);
setShowForgotSuccess(false);
setActiveTab("login");
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
}
}, [showRegisterSuccess, showForgotSuccess, redirectSeconds]);
useEffect(() => {
if (!(showRegisterSuccess || showForgotSuccess) && redirectTimer.current) {
clearInterval(redirectTimer.current);
}
}, [showRegisterSuccess, showForgotSuccess]);
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await forgotPassword({ email: forgotEmail });
toast({
title: "Email sent",
description: "Check your inbox to reset your password.",
});
setShowForgotSuccess(true);
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
redirectTimer.current = setInterval(() => {
setRedirectSeconds((s) => s - 1);
}, 1000);
} catch (error: any) {
let errorMessage = "Unable to send the reset password email. Please try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorMessage = error.response.data.detail;
} else if (error.response.data.detail) {
errorMessage = JSON.stringify(error.response.data.detail);
}
}
toast({
title: "Error sending email",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#121212] p-4">
<div className="mb-8">
<Image
src="https://evolution-api.com/files/evo/logo-evo-ai.svg"
alt="Evolution API"
width={140}
height={30}
priority
/>
</div>
<Card className="w-full max-w-md bg-[#1a1a1a] border-[#333]">
{showRegisterSuccess ? (
<div className="flex flex-col items-center justify-center p-8">
<CheckCircle2 className="w-12 h-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Registration successful!</h2>
<p className="text-neutral-300 mb-2 text-center">
Please check your email to confirm your account.<br />
Redirecting to login in {redirectSeconds} seconds...
</p>
</div>
) : showForgotSuccess ? (
<div className="flex flex-col items-center justify-center p-8">
<CheckCircle2 className="w-12 h-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Email sent!</h2>
<p className="text-neutral-300 mb-2 text-center">
Check your inbox to reset your password.<br />
Redirecting to login in {redirectSeconds} seconds...
</p>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 bg-[#222]">
<TabsTrigger
value="login"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Login
</TabsTrigger>
<TabsTrigger
value="register"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Register
</TabsTrigger>
<TabsTrigger
value="forgot"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Forgot
</TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={handleLogin}>
<CardHeader>
<CardTitle className="text-white">Login</CardTitle>
<CardDescription className="text-neutral-400">
Enter your credentials to access the system.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
required
value={loginData.email}
onChange={(e) =>
setLoginData({ ...loginData, email: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-neutral-300">
Password
</Label>
</div>
<Input
id="password"
type="password"
required
value={loginData.password}
onChange={(e) =>
setLoginData({ ...loginData, password: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
{loginError && (
<div className="text-red-500 text-sm mt-2" data-testid="login-error">
{isEmailNotVerified ? (
<div className="flex flex-col space-y-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{loginError}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={isResendingVerification}
className="text-emerald-400 border-emerald-400 hover:bg-emerald-400/10"
>
{isResendingVerification ? "Sending..." : "Resend verification email"}
</Button>
</div>
) : (
loginError
)}
</div>
)}
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Entering..." : "Enter"}
</Button>
</CardFooter>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={handleRegister}>
<CardHeader>
<CardTitle className="text-white">Register</CardTitle>
<CardDescription className="text-neutral-400">
Create a new account to get started.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="register-name" className="text-neutral-300">
Name
</Label>
<Input
id="register-name"
type="text"
placeholder="Your name"
required
value={registerData.name}
onChange={(e) =>
setRegisterData({ ...registerData, name: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="register-email" className="text-neutral-300">
Email
</Label>
<Input
id="register-email"
type="email"
placeholder="your@email.com"
required
value={registerData.email}
onChange={(e) =>
setRegisterData({
...registerData,
email: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="register-password" className="text-neutral-300">
Password
</Label>
<Input
id="register-password"
type="password"
required
value={registerData.password}
onChange={(e) =>
setRegisterData({
...registerData,
password: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="register-confirm-password"
className="text-neutral-300"
>
Confirm Password
</Label>
<Input
id="register-confirm-password"
type="password"
required
value={registerData.confirmPassword}
onChange={(e) =>
setRegisterData({
...registerData,
confirmPassword: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Registering..." : "Register"}
</Button>
</CardFooter>
</form>
</TabsContent>
<TabsContent value="forgot">
<form onSubmit={handleForgotPassword}>
<CardHeader>
<CardTitle className="text-white">Forgot Password</CardTitle>
<CardDescription className="text-neutral-400">
Enter your email to receive a password reset link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="forgot-email" className="text-neutral-300">
Email
</Label>
<Input
id="forgot-email"
type="email"
placeholder="your@email.com"
required
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Sending..." : "Send Link"}
</Button>
</CardFooter>
</form>
</TabsContent>
</Tabs>
)}
</Card>
<div className="mt-4 text-center text-sm text-neutral-500">
<p>
By using this service, you agree to our{" "}
<Link href="#" className="text-emerald-400 hover:underline">
Terms of Service
</Link>{" "}
e{" "}
<Link href="#" className="text-emerald-400 hover:underline">
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
/*
@author: Davidson Gomes
@file: /app/logout/page.tsx
Developed by: Davidson Gomes
Creation date: May 13, 2025
Contact: contato@evolution-api.com
@copyright © Evolution API 2025. All rights reserved.
Licensed under the Apache License, Version 2.0
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 │
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@important
For any future changes to the code in this file, it is recommended to
include, together with the modification, the information of the developer
who changed it and the date of modification.
*/
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { LogOut } from "lucide-react"
export default function LogoutPage() {
const router = useRouter()
useEffect(() => {
const performLogout = () => {
localStorage.removeItem("access_token")
localStorage.removeItem("user")
localStorage.removeItem("impersonatedClient")
localStorage.removeItem("isImpersonating")
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "impersonatedClient=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "isImpersonating=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
setTimeout(() => {
router.push("/login")
}, 1500)
}
performLogout()
}, [router])
return (
<div className="container mx-auto p-6 flex items-center justify-center min-h-[60vh]">
<Card className="bg-[#1a1a1a] border-[#333] w-full max-w-md p-8">
<CardContent className="flex flex-col items-center justify-center gap-4">
<div className="w-16 h-16 rounded-full bg-[#222] flex items-center justify-center animate-pulse">
<LogOut className="h-8 w-8 text-emerald-400" />
</div>
<h2 className="text-white text-xl font-medium mt-4">Logging out...</h2>
<p className="text-neutral-400 text-center">
You are being logged out of the system.
</p>
</CardContent>
</Card>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More