Spaces:
Configuration error
Configuration error
Commit ·
c1f8e04
0
Parent(s):
Initial commit
Browse files- .env.example +30 -0
- .gitattributes +2 -0
- .gitignore +87 -0
- docker-compose.prod.yml +126 -0
- docker-compose.yml +56 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/Dockerfile +23 -0
- frontend/README.md +36 -0
- frontend/app/(app)/agent/page.tsx +179 -0
- frontend/app/(app)/inbox/page.tsx +449 -0
- frontend/app/(app)/settings/page.tsx +138 -0
- frontend/app/(auth)/login/page.tsx +241 -0
- frontend/app/config.ts +2 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +120 -0
- frontend/app/layout.tsx +33 -0
- frontend/app/page.tsx +26 -0
- frontend/components/AgentRunTimeline.tsx +146 -0
- frontend/components/AgentStatusBar.tsx +105 -0
- frontend/components/EmailDetail.tsx +219 -0
- frontend/components/EmailList.tsx +183 -0
- frontend/components/StreamingDraft.tsx +217 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +7 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +27 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/tsconfig.json +34 -0
- grafana_dashboard.json +185 -0
- prometheus.yml +9 -0
.env.example
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Application settings
|
| 2 |
+
APP_ENV=development
|
| 3 |
+
JWT_SECRET=supersecretjwtkeypleasechangeinproduction12345
|
| 4 |
+
JWT_ALGORITHM=HS256
|
| 5 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
| 6 |
+
|
| 7 |
+
# Databases
|
| 8 |
+
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/gmail2
|
| 9 |
+
MONGODB_URI=mongodb://localhost:27017/
|
| 10 |
+
MONGODB_DB=gmail2
|
| 11 |
+
|
| 12 |
+
# Redis
|
| 13 |
+
REDIS_HOST=localhost
|
| 14 |
+
REDIS_PORT=6379
|
| 15 |
+
|
| 16 |
+
# Mock email provider (set to true to run offline / simulated)
|
| 17 |
+
MOCK_EMAIL_PROVIDER=true
|
| 18 |
+
|
| 19 |
+
# Google API Credentials (required if MOCK_EMAIL_PROVIDER=false)
|
| 20 |
+
GOOGLE_CLIENT_ID=your-google-client-id
|
| 21 |
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
| 22 |
+
GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
| 23 |
+
|
| 24 |
+
# OpenAI API (required for AI agents)
|
| 25 |
+
OPENAI_API_KEY=your-openai-api-key
|
| 26 |
+
|
| 27 |
+
# Optional LangSmith Tracing
|
| 28 |
+
LANGCHAIN_TRACING_V2=false
|
| 29 |
+
LANGCHAIN_API_KEY=your-langchain-api-key
|
| 30 |
+
LANGCHAIN_PROJECT=agentic-email-app
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
.gitignore
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment files (Secrets)
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.development.local
|
| 5 |
+
.env.test.local
|
| 6 |
+
.env.production.local
|
| 7 |
+
|
| 8 |
+
# Python virtual environments & packages
|
| 9 |
+
.venv/
|
| 10 |
+
venv/
|
| 11 |
+
env/
|
| 12 |
+
ENV/
|
| 13 |
+
python-local-packages/
|
| 14 |
+
|
| 15 |
+
# Python caches and build files
|
| 16 |
+
__pycache__/
|
| 17 |
+
*.py[cod]
|
| 18 |
+
*$py.class
|
| 19 |
+
*.so
|
| 20 |
+
.Python
|
| 21 |
+
build/
|
| 22 |
+
develop-eggs/
|
| 23 |
+
dist/
|
| 24 |
+
downloads/
|
| 25 |
+
eggs/
|
| 26 |
+
.eggs/
|
| 27 |
+
parts/
|
| 28 |
+
sdist/
|
| 29 |
+
var/
|
| 30 |
+
wheels/
|
| 31 |
+
*.egg-info/
|
| 32 |
+
.installed.cfg
|
| 33 |
+
*.egg
|
| 34 |
+
|
| 35 |
+
# Python testing & linting caches
|
| 36 |
+
.pytest_cache/
|
| 37 |
+
.tox/
|
| 38 |
+
.coverage
|
| 39 |
+
.coverage.*
|
| 40 |
+
.cache
|
| 41 |
+
nosetests.xml
|
| 42 |
+
coverage.xml
|
| 43 |
+
*.cover
|
| 44 |
+
.mypy_cache/
|
| 45 |
+
.ruff_cache/
|
| 46 |
+
|
| 47 |
+
# Node.js dependencies & builds (Frontend)
|
| 48 |
+
node_modules/
|
| 49 |
+
.next/
|
| 50 |
+
out/
|
| 51 |
+
frontend/node_modules/
|
| 52 |
+
frontend/.next/
|
| 53 |
+
frontend/out/
|
| 54 |
+
.npm
|
| 55 |
+
.eslintcache
|
| 56 |
+
|
| 57 |
+
# Logs and schedule data
|
| 58 |
+
*.log
|
| 59 |
+
npm-debug.log*
|
| 60 |
+
yarn-debug.log*
|
| 61 |
+
yarn-error.log*
|
| 62 |
+
celerybeat-schedule
|
| 63 |
+
celerybeat-schedule.*
|
| 64 |
+
dump.rdb
|
| 65 |
+
backend/celerybeat-schedule*
|
| 66 |
+
|
| 67 |
+
# Local development task states & compaction artifacts
|
| 68 |
+
.system_generated/
|
| 69 |
+
scratch/
|
| 70 |
+
Cwd/
|
| 71 |
+
*.bak
|
| 72 |
+
*.dat
|
| 73 |
+
*.dir
|
| 74 |
+
|
| 75 |
+
# Docker volumes and persistent state data
|
| 76 |
+
pgdata/
|
| 77 |
+
mongodata/
|
| 78 |
+
grafanadata/
|
| 79 |
+
|
| 80 |
+
# Operating system / IDE specific files
|
| 81 |
+
.DS_Store
|
| 82 |
+
Thumbs.db
|
| 83 |
+
.idea/
|
| 84 |
+
.vscode/
|
| 85 |
+
*.swp
|
| 86 |
+
*.swo
|
| 87 |
+
*.suo
|
docker-compose.prod.yml
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
postgres:
|
| 3 |
+
image: postgres:15-alpine
|
| 4 |
+
container_name: gmail2_postgres_prod
|
| 5 |
+
restart: always
|
| 6 |
+
environment:
|
| 7 |
+
POSTGRES_USER: postgres
|
| 8 |
+
POSTGRES_PASSWORD: postgres
|
| 9 |
+
POSTGRES_DB: gmail2
|
| 10 |
+
ports:
|
| 11 |
+
- "5432:5432"
|
| 12 |
+
volumes:
|
| 13 |
+
- pgdata:/var/lib/postgresql/data
|
| 14 |
+
|
| 15 |
+
mongodb:
|
| 16 |
+
image: mongo:6-jammy
|
| 17 |
+
container_name: gmail2_mongodb_prod
|
| 18 |
+
restart: always
|
| 19 |
+
ports:
|
| 20 |
+
- "27017:27017"
|
| 21 |
+
volumes:
|
| 22 |
+
- mongodata:/data/db
|
| 23 |
+
|
| 24 |
+
redis:
|
| 25 |
+
image: redis:7-alpine
|
| 26 |
+
container_name: gmail2_redis_prod
|
| 27 |
+
restart: always
|
| 28 |
+
ports:
|
| 29 |
+
- "6379:6379"
|
| 30 |
+
|
| 31 |
+
backend:
|
| 32 |
+
build:
|
| 33 |
+
context: ./backend
|
| 34 |
+
dockerfile: Dockerfile
|
| 35 |
+
container_name: gmail2_backend_prod
|
| 36 |
+
restart: always
|
| 37 |
+
ports:
|
| 38 |
+
- "8000:8000"
|
| 39 |
+
env_file:
|
| 40 |
+
- .env
|
| 41 |
+
environment:
|
| 42 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/gmail2
|
| 43 |
+
- MONGODB_URI=mongodb://mongodb:27017/
|
| 44 |
+
- REDIS_HOST=redis
|
| 45 |
+
- REDIS_PORT=6379
|
| 46 |
+
depends_on:
|
| 47 |
+
- postgres
|
| 48 |
+
- mongodb
|
| 49 |
+
- redis
|
| 50 |
+
|
| 51 |
+
celery_worker:
|
| 52 |
+
build:
|
| 53 |
+
context: ./backend
|
| 54 |
+
dockerfile: Dockerfile
|
| 55 |
+
container_name: gmail2_celery_worker_prod
|
| 56 |
+
restart: always
|
| 57 |
+
command: celery -A app.tasks.celery_app.celery_app worker --loglevel=info -P solo
|
| 58 |
+
env_file:
|
| 59 |
+
- .env
|
| 60 |
+
environment:
|
| 61 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/gmail2
|
| 62 |
+
- MONGODB_URI=mongodb://mongodb:27017/
|
| 63 |
+
- REDIS_HOST=redis
|
| 64 |
+
- REDIS_PORT=6379
|
| 65 |
+
depends_on:
|
| 66 |
+
- backend
|
| 67 |
+
- redis
|
| 68 |
+
|
| 69 |
+
celery_beat:
|
| 70 |
+
build:
|
| 71 |
+
context: ./backend
|
| 72 |
+
dockerfile: Dockerfile
|
| 73 |
+
container_name: gmail2_celery_beat_prod
|
| 74 |
+
restart: always
|
| 75 |
+
command: celery -A app.tasks.celery_app.celery_app beat --loglevel=info
|
| 76 |
+
env_file:
|
| 77 |
+
- .env
|
| 78 |
+
environment:
|
| 79 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/gmail2
|
| 80 |
+
- MONGODB_URI=mongodb://mongodb:27017/
|
| 81 |
+
- REDIS_HOST=redis
|
| 82 |
+
- REDIS_PORT=6379
|
| 83 |
+
depends_on:
|
| 84 |
+
- backend
|
| 85 |
+
- redis
|
| 86 |
+
|
| 87 |
+
frontend:
|
| 88 |
+
build:
|
| 89 |
+
context: ./frontend
|
| 90 |
+
dockerfile: Dockerfile
|
| 91 |
+
args:
|
| 92 |
+
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 93 |
+
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
|
| 94 |
+
container_name: gmail2_frontend_prod
|
| 95 |
+
restart: always
|
| 96 |
+
ports:
|
| 97 |
+
- "3000:3000"
|
| 98 |
+
depends_on:
|
| 99 |
+
- backend
|
| 100 |
+
|
| 101 |
+
prometheus:
|
| 102 |
+
image: prom/prometheus:latest
|
| 103 |
+
container_name: gmail2_prometheus_prod
|
| 104 |
+
restart: always
|
| 105 |
+
volumes:
|
| 106 |
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
| 107 |
+
ports:
|
| 108 |
+
- "9090:9090"
|
| 109 |
+
depends_on:
|
| 110 |
+
- backend
|
| 111 |
+
|
| 112 |
+
grafana:
|
| 113 |
+
image: grafana/grafana:latest
|
| 114 |
+
container_name: gmail2_grafana_prod
|
| 115 |
+
restart: always
|
| 116 |
+
ports:
|
| 117 |
+
- "3001:3000"
|
| 118 |
+
depends_on:
|
| 119 |
+
- prometheus
|
| 120 |
+
volumes:
|
| 121 |
+
- grafanadata:/var/lib/grafana
|
| 122 |
+
|
| 123 |
+
volumes:
|
| 124 |
+
pgdata:
|
| 125 |
+
mongodata:
|
| 126 |
+
grafanadata:
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
postgres:
|
| 3 |
+
image: postgres:15-alpine
|
| 4 |
+
container_name: gmail2_postgres
|
| 5 |
+
restart: always
|
| 6 |
+
environment:
|
| 7 |
+
POSTGRES_USER: postgres
|
| 8 |
+
POSTGRES_PASSWORD: postgres
|
| 9 |
+
POSTGRES_DB: gmail2
|
| 10 |
+
ports:
|
| 11 |
+
- "5432:5432"
|
| 12 |
+
volumes:
|
| 13 |
+
- pgdata:/var/lib/postgresql/data
|
| 14 |
+
|
| 15 |
+
mongodb:
|
| 16 |
+
image: mongo:6-jammy
|
| 17 |
+
container_name: gmail2_mongodb
|
| 18 |
+
restart: always
|
| 19 |
+
ports:
|
| 20 |
+
- "27017:27017"
|
| 21 |
+
volumes:
|
| 22 |
+
- mongodata:/data/db
|
| 23 |
+
|
| 24 |
+
redis:
|
| 25 |
+
image: redis:7-alpine
|
| 26 |
+
container_name: gmail2_redis
|
| 27 |
+
restart: always
|
| 28 |
+
ports:
|
| 29 |
+
- "6379:6379"
|
| 30 |
+
|
| 31 |
+
prometheus:
|
| 32 |
+
image: prom/prometheus:latest
|
| 33 |
+
container_name: gmail2_prometheus
|
| 34 |
+
restart: always
|
| 35 |
+
volumes:
|
| 36 |
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
| 37 |
+
ports:
|
| 38 |
+
- "9090:9090"
|
| 39 |
+
depends_on:
|
| 40 |
+
- postgres
|
| 41 |
+
|
| 42 |
+
grafana:
|
| 43 |
+
image: grafana/grafana:latest
|
| 44 |
+
container_name: gmail2_grafana
|
| 45 |
+
restart: always
|
| 46 |
+
ports:
|
| 47 |
+
- "3001:3000"
|
| 48 |
+
depends_on:
|
| 49 |
+
- prometheus
|
| 50 |
+
volumes:
|
| 51 |
+
- grafanadata:/var/lib/grafana
|
| 52 |
+
|
| 53 |
+
volumes:
|
| 54 |
+
pgdata:
|
| 55 |
+
mongodata:
|
| 56 |
+
grafanadata:
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:18-alpine AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package.json package-lock.json ./
|
| 4 |
+
RUN npm ci
|
| 5 |
+
COPY . .
|
| 6 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 7 |
+
ARG NEXT_PUBLIC_API_URL
|
| 8 |
+
ARG NEXT_PUBLIC_WS_URL
|
| 9 |
+
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
| 10 |
+
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
| 11 |
+
RUN npm run build
|
| 12 |
+
|
| 13 |
+
FROM node:18-alpine AS runner
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
ENV NODE_ENV production
|
| 16 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 17 |
+
COPY --from=builder /app/public ./public
|
| 18 |
+
COPY --from=builder /app/.next ./.next
|
| 19 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 20 |
+
COPY --from=builder /app/package.json ./package.json
|
| 21 |
+
|
| 22 |
+
EXPOSE 3000
|
| 23 |
+
CMD ["npm", "start"]
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/app/(app)/agent/page.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { Terminal, ShieldAlert, CheckCircle, XCircle, ArrowLeft, RefreshCw } from 'lucide-react';
|
| 6 |
+
import AgentRunTimeline from '@/components/AgentRunTimeline';
|
| 7 |
+
import { API_BASE_URL } from '@/app/config';
|
| 8 |
+
|
| 9 |
+
interface AgentRunListItem {
|
| 10 |
+
id: string;
|
| 11 |
+
action_type: string;
|
| 12 |
+
status: string;
|
| 13 |
+
started_at: string;
|
| 14 |
+
completed_at: string | null;
|
| 15 |
+
email_subject: string | null;
|
| 16 |
+
email_id: string | null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function AgentDashboard() {
|
| 20 |
+
const router = useRouter();
|
| 21 |
+
const [runs, setRuns] = useState<AgentRunListItem[]>([]);
|
| 22 |
+
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
| 23 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
fetchRuns();
|
| 27 |
+
}, []);
|
| 28 |
+
|
| 29 |
+
const fetchRuns = async () => {
|
| 30 |
+
setIsLoading(true);
|
| 31 |
+
const token = localStorage.getItem('token') || '';
|
| 32 |
+
try {
|
| 33 |
+
const response = await fetch(`${API_BASE_URL}/api/agent/runs`, {
|
| 34 |
+
headers: {
|
| 35 |
+
'Authorization': `Bearer ${token}`,
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
if (response.ok) {
|
| 39 |
+
const data = await response.json();
|
| 40 |
+
setRuns(data);
|
| 41 |
+
}
|
| 42 |
+
} catch (err) {
|
| 43 |
+
console.error(err);
|
| 44 |
+
} finally {
|
| 45 |
+
setIsLoading(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const getStatusIcon = (status: string) => {
|
| 50 |
+
switch (status.toUpperCase()) {
|
| 51 |
+
case 'COMPLETED':
|
| 52 |
+
return <CheckCircle size={15} className="text-emerald-400" />;
|
| 53 |
+
case 'FAILED':
|
| 54 |
+
return <XCircle size={15} className="text-red-400" />;
|
| 55 |
+
default:
|
| 56 |
+
return <RefreshCw size={15} className="text-purple-400 animate-spin" />;
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const getStatusClass = (status: string) => {
|
| 61 |
+
switch (status.toUpperCase()) {
|
| 62 |
+
case 'COMPLETED':
|
| 63 |
+
return 'text-emerald-400 bg-emerald-950/20 border-emerald-900/30';
|
| 64 |
+
case 'FAILED':
|
| 65 |
+
return 'text-red-400 bg-red-950/20 border-red-900/30';
|
| 66 |
+
default:
|
| 67 |
+
return 'text-purple-400 bg-purple-950/20 border-purple-900/30';
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="min-h-screen bg-[#06060c] px-8 py-10 relative overflow-hidden text-slate-200">
|
| 73 |
+
{/* Background glow */}
|
| 74 |
+
<div className="absolute top-1/4 right-1/4 w-[400px] h-[400px] bg-purple-950/5 rounded-full blur-[120px] pointer-events-none" />
|
| 75 |
+
|
| 76 |
+
<div className="max-w-6xl mx-auto flex flex-col gap-8 relative z-10">
|
| 77 |
+
{/* Header */}
|
| 78 |
+
<div className="flex justify-between items-center">
|
| 79 |
+
<div className="flex items-center gap-4">
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => router.push('/inbox')}
|
| 82 |
+
className="p-2 hover:bg-slate-900 border border-slate-900 hover:border-slate-800 rounded-xl transition text-slate-400 hover:text-white cursor-pointer"
|
| 83 |
+
>
|
| 84 |
+
<ArrowLeft size={16} />
|
| 85 |
+
</button>
|
| 86 |
+
<div>
|
| 87 |
+
<h1 className="text-2xl font-bold text-white tracking-wide">Agent Audit Hub</h1>
|
| 88 |
+
<p className="text-xs text-slate-500 mt-1">Audit historic runs and step-by-step thinking logs</p>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
<button
|
| 92 |
+
onClick={fetchRuns}
|
| 93 |
+
disabled={isLoading}
|
| 94 |
+
className="bg-slate-950 hover:bg-slate-900 border border-slate-800 text-slate-300 hover:text-white font-medium py-2 px-4 rounded-xl text-xs flex items-center gap-2 cursor-pointer transition"
|
| 95 |
+
>
|
| 96 |
+
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
| 97 |
+
<span>Refresh Audit</span>
|
| 98 |
+
</button>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{/* Workspace Layout */}
|
| 102 |
+
<div className="grid grid-cols-12 gap-8">
|
| 103 |
+
{/* Runs list column */}
|
| 104 |
+
<div className="col-span-7 flex flex-col gap-4">
|
| 105 |
+
<div className="glass-panel border-slate-900 rounded-2xl overflow-hidden">
|
| 106 |
+
<table className="w-full text-left border-collapse">
|
| 107 |
+
<thead>
|
| 108 |
+
<tr className="bg-slate-950/50 border-b border-slate-900/60 text-slate-500 text-[10px] uppercase font-bold tracking-widest">
|
| 109 |
+
<th className="py-4 px-6">Task Type</th>
|
| 110 |
+
<th className="py-4 px-6">Target Subject</th>
|
| 111 |
+
<th className="py-4 px-6">Status</th>
|
| 112 |
+
<th className="py-4 px-6">Start Time</th>
|
| 113 |
+
</tr>
|
| 114 |
+
</thead>
|
| 115 |
+
<tbody className="divide-y divide-slate-900/40 text-xs">
|
| 116 |
+
{runs.length === 0 ? (
|
| 117 |
+
<tr>
|
| 118 |
+
<td colSpan={4} className="py-12 text-center text-slate-500 italic">
|
| 119 |
+
No agent run history found. Try syncing some emails first.
|
| 120 |
+
</td>
|
| 121 |
+
</tr>
|
| 122 |
+
) : (
|
| 123 |
+
runs.map((run) => {
|
| 124 |
+
const isSelected = selectedRunId === run.id;
|
| 125 |
+
return (
|
| 126 |
+
<tr
|
| 127 |
+
key={run.id}
|
| 128 |
+
onClick={() => setSelectedRunId(run.id)}
|
| 129 |
+
className={`hover:bg-slate-900/20 cursor-pointer transition ${
|
| 130 |
+
isSelected ? 'bg-purple-950/10' : ''
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
<td className="py-4 px-6 font-semibold text-slate-200">
|
| 134 |
+
{run.action_type}
|
| 135 |
+
</td>
|
| 136 |
+
<td className="py-4 px-6 text-slate-400 font-medium truncate max-w-[200px]">
|
| 137 |
+
{run.email_subject || 'System Trigger'}
|
| 138 |
+
</td>
|
| 139 |
+
<td className="py-4 px-6">
|
| 140 |
+
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded border text-[10px] uppercase font-bold ${getStatusClass(run.status)}`}>
|
| 141 |
+
{getStatusIcon(run.status)}
|
| 142 |
+
<span>{run.status}</span>
|
| 143 |
+
</span>
|
| 144 |
+
</td>
|
| 145 |
+
<td className="py-4 px-6 font-mono text-slate-500 font-medium">
|
| 146 |
+
{new Date(run.started_at).toLocaleTimeString()}
|
| 147 |
+
</td>
|
| 148 |
+
</tr>
|
| 149 |
+
);
|
| 150 |
+
})
|
| 151 |
+
)}
|
| 152 |
+
</tbody>
|
| 153 |
+
</table>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Timeline Audit Logs column */}
|
| 158 |
+
<div className="col-span-5">
|
| 159 |
+
{selectedRunId ? (
|
| 160 |
+
<div className="glass-panel border-purple-500/10 rounded-2xl p-6 h-[550px] overflow-y-auto">
|
| 161 |
+
<AgentRunTimeline runId={selectedRunId} />
|
| 162 |
+
</div>
|
| 163 |
+
) : (
|
| 164 |
+
<div className="glass-panel border-slate-900 rounded-2xl p-8 flex flex-col items-center justify-center text-slate-500 text-center h-[350px] gap-3">
|
| 165 |
+
<Terminal size={24} className="stroke-[1.5]" />
|
| 166 |
+
<div>
|
| 167 |
+
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Select Execution</h3>
|
| 168 |
+
<p className="text-[11px] leading-relaxed max-w-[200px] mx-auto">
|
| 169 |
+
Click an agent row on the left to review its detailed planning and API integration logs.
|
| 170 |
+
</p>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
);
|
| 179 |
+
}
|
frontend/app/(app)/inbox/page.tsx
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { Inbox, FileText, Settings, LogOut, Terminal, Activity, AlertOctagon } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
import EmailList from '@/components/EmailList';
|
| 8 |
+
import EmailDetail from '@/components/EmailDetail';
|
| 9 |
+
import StreamingDraft from '@/components/StreamingDraft';
|
| 10 |
+
import AgentStatusBar from '@/components/AgentStatusBar';
|
| 11 |
+
import AgentRunTimeline from '@/components/AgentRunTimeline';
|
| 12 |
+
import { API_BASE_URL, WS_BASE_URL } from '@/app/config';
|
| 13 |
+
|
| 14 |
+
interface EmailListItem {
|
| 15 |
+
id: string;
|
| 16 |
+
subject?: string;
|
| 17 |
+
sender: string;
|
| 18 |
+
received_at: string;
|
| 19 |
+
category: string;
|
| 20 |
+
is_read: boolean;
|
| 21 |
+
is_starred: boolean;
|
| 22 |
+
thread_id?: string;
|
| 23 |
+
has_attachment: boolean;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface AgentStatus {
|
| 27 |
+
status: string;
|
| 28 |
+
progress: number;
|
| 29 |
+
active: boolean;
|
| 30 |
+
runId?: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface AlertToast {
|
| 34 |
+
id: string;
|
| 35 |
+
message: string;
|
| 36 |
+
emailId?: string;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export default function InboxPage() {
|
| 40 |
+
const router = useRouter();
|
| 41 |
+
const [token, setToken] = useState<string | null>(null);
|
| 42 |
+
const [userEmail, setUserEmail] = useState<string>('user');
|
| 43 |
+
|
| 44 |
+
// Data states
|
| 45 |
+
const [emails, setEmails] = useState<EmailListItem[]>([]);
|
| 46 |
+
const [selectedEmail, setSelectedEmail] = useState<any | null>(null);
|
| 47 |
+
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null);
|
| 48 |
+
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
| 49 |
+
|
| 50 |
+
// Status states
|
| 51 |
+
const [isSyncing, setIsSyncing] = useState(false);
|
| 52 |
+
const [emailLoading, setEmailLoading] = useState(false);
|
| 53 |
+
const [agentStatus, setAgentStatus] = useState<AgentStatus>({
|
| 54 |
+
status: 'Sleeping',
|
| 55 |
+
progress: 100,
|
| 56 |
+
active: false,
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
// Timeline audit log states
|
| 60 |
+
const [activeAuditRunId, setActiveAuditRunId] = useState<string | null>(null);
|
| 61 |
+
const [showAuditPanel, setShowAuditPanel] = useState(false);
|
| 62 |
+
const [alerts, setAlerts] = useState<AlertToast[]>([]);
|
| 63 |
+
|
| 64 |
+
const wsRef = useRef<WebSocket | null>(null);
|
| 65 |
+
|
| 66 |
+
// Authenticate user & load first data
|
| 67 |
+
useEffect(() => {
|
| 68 |
+
const savedToken = localStorage.getItem('token');
|
| 69 |
+
if (!savedToken) {
|
| 70 |
+
router.push('/login');
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
setToken(savedToken);
|
| 74 |
+
|
| 75 |
+
const savedEmail = localStorage.getItem('email');
|
| 76 |
+
if (savedEmail) {
|
| 77 |
+
setUserEmail(savedEmail);
|
| 78 |
+
}
|
| 79 |
+
}, [router]);
|
| 80 |
+
|
| 81 |
+
// Fetch emails when category or token changes
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
if (token) {
|
| 84 |
+
fetchEmails();
|
| 85 |
+
}
|
| 86 |
+
}, [token, activeCategory]);
|
| 87 |
+
|
| 88 |
+
// Handle WebSocket updates
|
| 89 |
+
useEffect(() => {
|
| 90 |
+
if (!token) return;
|
| 91 |
+
|
| 92 |
+
// Connect to WebSocket Server
|
| 93 |
+
const wsUrl = `${WS_BASE_URL}/ws?token=${encodeURIComponent(token)}`;
|
| 94 |
+
const ws = new WebSocket(wsUrl);
|
| 95 |
+
wsRef.current = ws;
|
| 96 |
+
|
| 97 |
+
ws.onopen = () => {
|
| 98 |
+
console.log('WebSocket connection established.');
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
ws.onmessage = (event) => {
|
| 102 |
+
try {
|
| 103 |
+
const data = JSON.parse(event.data);
|
| 104 |
+
console.log('WS update received:', data);
|
| 105 |
+
|
| 106 |
+
// WebSocket payload format:
|
| 107 |
+
// { "status": "Analyzing email...", "progress": 40, "run_id": "...", "email_id": "...", "type": "alert" }
|
| 108 |
+
const status = data.status || '';
|
| 109 |
+
const progress = typeof data.progress === 'number' ? data.progress : 100;
|
| 110 |
+
const runId = data.run_id;
|
| 111 |
+
const emailId = data.email_id;
|
| 112 |
+
const type = data.type;
|
| 113 |
+
|
| 114 |
+
// If it's a popup alert
|
| 115 |
+
if (type === 'alert') {
|
| 116 |
+
const newAlert: AlertToast = {
|
| 117 |
+
id: Math.random().toString(),
|
| 118 |
+
message: status,
|
| 119 |
+
emailId: emailId,
|
| 120 |
+
};
|
| 121 |
+
setAlerts((prev) => [newAlert, ...prev]);
|
| 122 |
+
|
| 123 |
+
// Clear alert after 8 seconds
|
| 124 |
+
setTimeout(() => {
|
| 125 |
+
setAlerts((prev) => prev.filter((a) => a.id !== newAlert.id));
|
| 126 |
+
}, 8000);
|
| 127 |
+
|
| 128 |
+
// Force refetch email list
|
| 129 |
+
fetchEmails();
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Update progress bar
|
| 134 |
+
setAgentStatus({
|
| 135 |
+
status,
|
| 136 |
+
progress,
|
| 137 |
+
active: progress < 100,
|
| 138 |
+
runId,
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
if (runId) {
|
| 142 |
+
setActiveAuditRunId(runId);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Refetch emails if analysis completed or status sync completes
|
| 146 |
+
if (progress === 100) {
|
| 147 |
+
fetchEmails();
|
| 148 |
+
// If the email currently opened is the one that finished sync, reload it
|
| 149 |
+
if (emailId && selectedEmailId === emailId) {
|
| 150 |
+
fetchEmailDetails(emailId);
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
} catch (err) {
|
| 154 |
+
console.error('Failed to parse WS packet:', err);
|
| 155 |
+
}
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
ws.onclose = () => {
|
| 159 |
+
console.log('WebSocket disconnected. Reconnecting in 5 seconds...');
|
| 160 |
+
setTimeout(() => {
|
| 161 |
+
// Reconnect if tab is still active
|
| 162 |
+
if (localStorage.getItem('token')) {
|
| 163 |
+
setToken(localStorage.getItem('token'));
|
| 164 |
+
}
|
| 165 |
+
}, 5000);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
return () => {
|
| 169 |
+
if (wsRef.current) {
|
| 170 |
+
wsRef.current.close();
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
}, [token, selectedEmailId]);
|
| 174 |
+
|
| 175 |
+
const fetchEmails = async () => {
|
| 176 |
+
if (!token) return;
|
| 177 |
+
const url = activeCategory
|
| 178 |
+
? `${API_BASE_URL}/api/emails?category=${activeCategory}`
|
| 179 |
+
: `${API_BASE_URL}/api/emails`;
|
| 180 |
+
|
| 181 |
+
try {
|
| 182 |
+
const response = await fetch(url, {
|
| 183 |
+
headers: {
|
| 184 |
+
'Authorization': `Bearer ${token}`,
|
| 185 |
+
},
|
| 186 |
+
});
|
| 187 |
+
if (response.ok) {
|
| 188 |
+
const data = await response.json();
|
| 189 |
+
setEmails(data);
|
| 190 |
+
}
|
| 191 |
+
} catch (err) {
|
| 192 |
+
console.error('Error fetching emails:', err);
|
| 193 |
+
}
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const fetchEmailDetails = async (id: string) => {
|
| 197 |
+
if (!token) return;
|
| 198 |
+
setEmailLoading(true);
|
| 199 |
+
try {
|
| 200 |
+
const response = await fetch(`${API_BASE_URL}/api/emails/${id}`, {
|
| 201 |
+
headers: {
|
| 202 |
+
'Authorization': `Bearer ${token}`,
|
| 203 |
+
},
|
| 204 |
+
});
|
| 205 |
+
if (response.ok) {
|
| 206 |
+
const data = await response.json();
|
| 207 |
+
setSelectedEmail(data);
|
| 208 |
+
|
| 209 |
+
// Mark as read in local list
|
| 210 |
+
setEmails((prev) =>
|
| 211 |
+
prev.map((e) => (e.id === id ? { ...e, is_read: true } : e))
|
| 212 |
+
);
|
| 213 |
+
}
|
| 214 |
+
} catch (err) {
|
| 215 |
+
console.error('Error fetching email details:', err);
|
| 216 |
+
} finally {
|
| 217 |
+
setEmailLoading(false);
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
const handleSelectEmail = (id: string) => {
|
| 222 |
+
setSelectedEmailId(id);
|
| 223 |
+
fetchEmailDetails(id);
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
const handleToggleStar = async (id: string, isStarred: boolean, e: React.MouseEvent) => {
|
| 227 |
+
e.stopPropagation(); // Avoid selecting row
|
| 228 |
+
if (!token) return;
|
| 229 |
+
try {
|
| 230 |
+
const response = await fetch(`${API_BASE_URL}/api/emails/${id}/toggle-star?is_starred=${isStarred}`, {
|
| 231 |
+
method: 'POST',
|
| 232 |
+
headers: {
|
| 233 |
+
'Authorization': `Bearer ${token}`,
|
| 234 |
+
},
|
| 235 |
+
});
|
| 236 |
+
if (response.ok) {
|
| 237 |
+
setEmails((prev) =>
|
| 238 |
+
prev.map((item) => (item.id === id ? { ...item, is_starred: isStarred } : item))
|
| 239 |
+
);
|
| 240 |
+
if (selectedEmail && selectedEmail.id === id) {
|
| 241 |
+
setSelectedEmail((prev: any) => ({ ...prev, is_starred: isStarred }));
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
} catch (err) {
|
| 245 |
+
console.error(err);
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
const handleTriggerSync = async () => {
|
| 250 |
+
if (!token) return;
|
| 251 |
+
setIsSyncing(true);
|
| 252 |
+
try {
|
| 253 |
+
const response = await fetch(`${API_BASE_URL}/api/agent/trigger-sync`, {
|
| 254 |
+
method: 'POST',
|
| 255 |
+
headers: {
|
| 256 |
+
'Authorization': `Bearer ${token}`,
|
| 257 |
+
},
|
| 258 |
+
});
|
| 259 |
+
if (!response.ok) {
|
| 260 |
+
throw new Error('Failed to trigger inbox sync');
|
| 261 |
+
}
|
| 262 |
+
} catch (err) {
|
| 263 |
+
console.error(err);
|
| 264 |
+
} finally {
|
| 265 |
+
setIsSyncing(false);
|
| 266 |
+
}
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const handleSendEmail = async (to: string, subject: string, body: string): Promise<boolean> => {
|
| 270 |
+
if (!token) return false;
|
| 271 |
+
try {
|
| 272 |
+
const response = await fetch(`${API_BASE_URL}/api/emails/send`, {
|
| 273 |
+
method: 'POST',
|
| 274 |
+
headers: {
|
| 275 |
+
'Content-Type': 'application/json',
|
| 276 |
+
'Authorization': `Bearer ${token}`,
|
| 277 |
+
},
|
| 278 |
+
body: JSON.stringify({ to, subject, body }),
|
| 279 |
+
});
|
| 280 |
+
return response.ok;
|
| 281 |
+
} catch (err) {
|
| 282 |
+
console.error(err);
|
| 283 |
+
return false;
|
| 284 |
+
}
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
const handleLogout = () => {
|
| 288 |
+
localStorage.clear();
|
| 289 |
+
router.push('/login');
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
const toggleAuditPanel = (runId: string) => {
|
| 293 |
+
setActiveAuditRunId(runId);
|
| 294 |
+
setShowAuditPanel(true);
|
| 295 |
+
};
|
| 296 |
+
|
| 297 |
+
return (
|
| 298 |
+
<div className="min-h-screen bg-[#06060c] flex relative overflow-hidden text-slate-200">
|
| 299 |
+
{/* Decorative background glows */}
|
| 300 |
+
<div className="absolute top-0 right-1/4 w-[500px] h-[500px] bg-purple-950/10 rounded-full blur-[140px] pointer-events-none" />
|
| 301 |
+
<div className="absolute bottom-0 left-1/4 w-[500px] h-[500px] bg-cyan-950/5 rounded-full blur-[140px] pointer-events-none" />
|
| 302 |
+
|
| 303 |
+
{/* Floating Alerts Container */}
|
| 304 |
+
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3 max-w-sm">
|
| 305 |
+
{alerts.map((alert) => (
|
| 306 |
+
<div
|
| 307 |
+
key={alert.id}
|
| 308 |
+
onClick={() => alert.emailId && handleSelectEmail(alert.emailId)}
|
| 309 |
+
className="glass-panel border-cyan-500/30 p-4 rounded-xl flex gap-3 shadow-xl cursor-pointer hover:border-cyan-400 hover:shadow-[0_0_15px_rgba(6,182,212,0.15)] transition-all animate-fade-in-up items-center"
|
| 310 |
+
>
|
| 311 |
+
<div className="h-8 w-8 rounded-full bg-cyan-950/50 flex items-center justify-center text-cyan-400 shrink-0">
|
| 312 |
+
<Activity size={16} />
|
| 313 |
+
</div>
|
| 314 |
+
<div>
|
| 315 |
+
<p className="text-xs font-semibold text-slate-200">{alert.message}</p>
|
| 316 |
+
{alert.emailId && <p className="text-[10px] text-cyan-400 mt-0.5">Click to view details</p>}
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
))}
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
{/* Navigation Rail */}
|
| 323 |
+
<aside className="w-16 bg-slate-950/60 border-r border-slate-900/60 flex flex-col items-center py-6 justify-between relative z-10 shrink-0">
|
| 324 |
+
<div className="flex flex-col items-center gap-8">
|
| 325 |
+
<div className="h-10 w-10 bg-purple-950/50 border border-purple-500/25 text-purple-400 rounded-xl flex items-center justify-center font-bold text-lg shadow-[0_0_10px_rgba(147,51,234,0.1)]">
|
| 326 |
+
A
|
| 327 |
+
</div>
|
| 328 |
+
<nav className="flex flex-col gap-6">
|
| 329 |
+
<button className="h-10 w-10 bg-purple-600/15 border border-purple-500/30 text-purple-300 rounded-xl flex items-center justify-center cursor-pointer transition">
|
| 330 |
+
<Inbox size={18} />
|
| 331 |
+
</button>
|
| 332 |
+
<button
|
| 333 |
+
onClick={() => router.push('/agent')}
|
| 334 |
+
className="h-10 w-10 hover:bg-slate-900/60 border border-transparent text-slate-500 hover:text-slate-300 rounded-xl flex items-center justify-center cursor-pointer transition"
|
| 335 |
+
>
|
| 336 |
+
<Terminal size={18} />
|
| 337 |
+
</button>
|
| 338 |
+
<button
|
| 339 |
+
onClick={() => router.push('/settings')}
|
| 340 |
+
className="h-10 w-10 hover:bg-slate-900/60 border border-transparent text-slate-500 hover:text-slate-300 rounded-xl flex items-center justify-center cursor-pointer transition"
|
| 341 |
+
>
|
| 342 |
+
<Settings size={18} />
|
| 343 |
+
</button>
|
| 344 |
+
</nav>
|
| 345 |
+
</div>
|
| 346 |
+
<button
|
| 347 |
+
onClick={handleLogout}
|
| 348 |
+
className="h-10 w-10 hover:bg-red-950/20 border border-transparent hover:border-red-950 text-slate-500 hover:text-red-400 rounded-xl flex items-center justify-center cursor-pointer transition"
|
| 349 |
+
>
|
| 350 |
+
<LogOut size={18} />
|
| 351 |
+
</button>
|
| 352 |
+
</aside>
|
| 353 |
+
|
| 354 |
+
{/* Main App Workspace */}
|
| 355 |
+
<main className="flex-1 flex overflow-hidden relative z-10">
|
| 356 |
+
<div className="flex-1 flex flex-col min-w-0">
|
| 357 |
+
<header className="h-16 border-b border-slate-900/80 px-6 flex items-center justify-between shrink-0 bg-slate-950/20">
|
| 358 |
+
<h1 className="text-base font-bold text-white tracking-wide flex items-center gap-2">
|
| 359 |
+
<span>Inbox Workspace</span>
|
| 360 |
+
</h1>
|
| 361 |
+
<div className="flex items-center gap-3">
|
| 362 |
+
<span className="text-xs text-slate-500">Logged in:</span>
|
| 363 |
+
<span className="text-xs bg-slate-900 border border-slate-800 px-3 py-1 rounded-full text-slate-300 font-medium font-mono">
|
| 364 |
+
{userEmail}
|
| 365 |
+
</span>
|
| 366 |
+
</div>
|
| 367 |
+
</header>
|
| 368 |
+
|
| 369 |
+
{/* Grid Layout of the Content */}
|
| 370 |
+
<div className="flex-1 flex overflow-hidden p-6 gap-6">
|
| 371 |
+
{/* Column 1: EmailList */}
|
| 372 |
+
<div className="w-80 shrink-0">
|
| 373 |
+
<EmailList
|
| 374 |
+
emails={emails}
|
| 375 |
+
selectedId={selectedEmailId}
|
| 376 |
+
onSelectEmail={handleSelectEmail}
|
| 377 |
+
onToggleStar={handleToggleStar}
|
| 378 |
+
activeCategory={activeCategory}
|
| 379 |
+
setActiveCategory={setActiveCategory}
|
| 380 |
+
/>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
{/* Column 2: Detailed View & AI Assistant Panel */}
|
| 384 |
+
<div className="flex-1 flex flex-col gap-6 min-w-0">
|
| 385 |
+
{selectedEmail ? (
|
| 386 |
+
<>
|
| 387 |
+
<div className="flex-1 min-h-0">
|
| 388 |
+
<EmailDetail email={selectedEmail} isLoading={emailLoading} />
|
| 389 |
+
</div>
|
| 390 |
+
<div className="shrink-0">
|
| 391 |
+
<StreamingDraft
|
| 392 |
+
emailId={selectedEmail.id}
|
| 393 |
+
emailSubject={selectedEmail.subject}
|
| 394 |
+
emailSender={selectedEmail.sender}
|
| 395 |
+
onSendEmail={handleSendEmail}
|
| 396 |
+
/>
|
| 397 |
+
</div>
|
| 398 |
+
</>
|
| 399 |
+
) : (
|
| 400 |
+
<div className="flex-1 glass-panel border-dashed border-slate-800 rounded-2xl flex flex-col items-center justify-center text-slate-500 gap-3">
|
| 401 |
+
<Inbox size={32} className="stroke-[1.2]" />
|
| 402 |
+
<div className="text-center">
|
| 403 |
+
<p className="text-sm font-semibold text-slate-300">Select an email to view</p>
|
| 404 |
+
<p className="text-xs text-slate-500 mt-1 max-w-[240px]">
|
| 405 |
+
Open messages from the list to display details and access the AI draft composer.
|
| 406 |
+
</p>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
)}
|
| 410 |
+
</div>
|
| 411 |
+
|
| 412 |
+
{/* Column 3: Status & Auditing Bar */}
|
| 413 |
+
<div className="w-80 shrink-0 flex flex-col gap-6 overflow-y-auto">
|
| 414 |
+
<AgentStatusBar
|
| 415 |
+
statusData={agentStatus}
|
| 416 |
+
isSyncing={isSyncing}
|
| 417 |
+
onTriggerSync={handleTriggerSync}
|
| 418 |
+
onViewLogs={toggleAuditPanel}
|
| 419 |
+
/>
|
| 420 |
+
|
| 421 |
+
{/* Audit Timeline */}
|
| 422 |
+
{showAuditPanel && activeAuditRunId ? (
|
| 423 |
+
<div className="glass-panel border-purple-500/10 rounded-2xl p-5 relative">
|
| 424 |
+
<button
|
| 425 |
+
onClick={() => setShowAuditPanel(false)}
|
| 426 |
+
className="absolute top-4 right-4 text-[10px] uppercase font-bold text-slate-500 hover:text-slate-300"
|
| 427 |
+
>
|
| 428 |
+
Close
|
| 429 |
+
</button>
|
| 430 |
+
<AgentRunTimeline runId={activeAuditRunId} />
|
| 431 |
+
</div>
|
| 432 |
+
) : (
|
| 433 |
+
<div className="glass-panel border-slate-900 rounded-2xl p-6 flex flex-col items-center justify-center text-slate-500 gap-3 text-center h-[280px]">
|
| 434 |
+
<Activity size={24} className="stroke-[1.5]" />
|
| 435 |
+
<div>
|
| 436 |
+
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Audit Trail</h4>
|
| 437 |
+
<p className="text-[11px] leading-relaxed">
|
| 438 |
+
Start syncing or analyze an email to inspect the AI agent thinking logs here.
|
| 439 |
+
</p>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
</main>
|
| 447 |
+
</div>
|
| 448 |
+
);
|
| 449 |
+
}
|
frontend/app/(app)/settings/page.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { Settings, Shield, Sliders, ArrowLeft, Key, User, FileText, CheckCircle2 } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function SettingsPage() {
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
const [email, setEmail] = useState('Unknown user');
|
| 10 |
+
const [userId, setUserId] = useState('Unknown');
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const savedEmail = localStorage.getItem('email');
|
| 14 |
+
const savedUserId = localStorage.getItem('userId');
|
| 15 |
+
if (savedEmail) setEmail(savedEmail);
|
| 16 |
+
if (savedUserId) setUserId(savedUserId);
|
| 17 |
+
}, []);
|
| 18 |
+
|
| 19 |
+
const handleLogout = () => {
|
| 20 |
+
localStorage.clear();
|
| 21 |
+
router.push('/login');
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="min-h-screen bg-[#06060c] px-8 py-10 relative overflow-hidden text-slate-200">
|
| 26 |
+
<div className="absolute top-1/4 left-1/4 w-[350px] h-[350px] bg-cyan-950/5 rounded-full blur-[120px] pointer-events-none" />
|
| 27 |
+
|
| 28 |
+
<div className="max-w-3xl mx-auto flex flex-col gap-8 relative z-10 animate-fade-in-up">
|
| 29 |
+
{/* Header */}
|
| 30 |
+
<div className="flex items-center gap-4">
|
| 31 |
+
<button
|
| 32 |
+
onClick={() => router.push('/inbox')}
|
| 33 |
+
className="p-2 hover:bg-slate-900 border border-slate-900 hover:border-slate-800 rounded-xl transition text-slate-400 hover:text-white cursor-pointer"
|
| 34 |
+
>
|
| 35 |
+
<ArrowLeft size={16} />
|
| 36 |
+
</button>
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="text-2xl font-bold text-white tracking-wide">System Settings</h1>
|
| 39 |
+
<p className="text-xs text-slate-500 mt-1">Configure credentials and review application connections</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
{/* Content Box */}
|
| 44 |
+
<div className="flex flex-col gap-6">
|
| 45 |
+
{/* Section 1: User Profile */}
|
| 46 |
+
<div className="glass-panel border-slate-900 rounded-2xl p-6 flex flex-col gap-4">
|
| 47 |
+
<div className="flex items-center gap-2 text-purple-400 font-semibold text-xs uppercase tracking-wider">
|
| 48 |
+
<User size={14} />
|
| 49 |
+
<span>User Profile</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="grid grid-cols-2 gap-4 text-xs">
|
| 52 |
+
<div>
|
| 53 |
+
<span className="text-slate-500 block mb-1">Authenticated Account</span>
|
| 54 |
+
<span className="font-semibold text-slate-200 font-mono">
|
| 55 |
+
{email}
|
| 56 |
+
</span>
|
| 57 |
+
</div>
|
| 58 |
+
<div>
|
| 59 |
+
<span className="text-slate-500 block mb-1">Local User ID</span>
|
| 60 |
+
<span className="font-semibold text-slate-300 font-mono truncate block">
|
| 61 |
+
{userId}
|
| 62 |
+
</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{/* Section 2: Integration status */}
|
| 68 |
+
<div className="glass-panel border-slate-900 rounded-2xl p-6 flex flex-col gap-4">
|
| 69 |
+
<div className="flex items-center gap-2 text-cyan-400 font-semibold text-xs uppercase tracking-wider">
|
| 70 |
+
<Sliders size={14} />
|
| 71 |
+
<span>Integration Settings</span>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="flex flex-col gap-3.5">
|
| 74 |
+
<div className="flex justify-between items-center bg-slate-950/40 p-4 border border-slate-900 rounded-xl text-xs">
|
| 75 |
+
<div>
|
| 76 |
+
<span className="font-semibold text-slate-200 block">Email Mock Mode</span>
|
| 77 |
+
<span className="text-slate-500 block mt-0.5">Offline mock sync handles mail simulations</span>
|
| 78 |
+
</div>
|
| 79 |
+
<span className="px-3 py-1 bg-cyan-950/40 text-cyan-400 border border-cyan-800/30 rounded-full font-bold">
|
| 80 |
+
ENABLED (Fallback)
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="flex justify-between items-center bg-slate-950/40 p-4 border border-slate-900 rounded-xl text-xs">
|
| 85 |
+
<div>
|
| 86 |
+
<span className="font-semibold text-slate-200 block">Google API Authorization Scope</span>
|
| 87 |
+
<span className="text-slate-500 block mt-0.5">OAuth scopes to read and send messages</span>
|
| 88 |
+
</div>
|
| 89 |
+
<span className="text-slate-400 bg-slate-900 px-3 py-1 border border-slate-800 rounded-full font-mono">
|
| 90 |
+
gmail.readonly, gmail.compose
|
| 91 |
+
</span>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="flex justify-between items-center bg-slate-950/40 p-4 border border-slate-900 rounded-xl text-xs">
|
| 95 |
+
<div>
|
| 96 |
+
<span className="font-semibold text-slate-200 block">OpenAI GPT-4o-Mini Engine</span>
|
| 97 |
+
<span className="text-slate-500 block mt-0.5">Drives triage, TL;DR and streaming reply composer</span>
|
| 98 |
+
</div>
|
| 99 |
+
<span className="px-3 py-1 bg-emerald-950/40 text-emerald-400 border border-emerald-800/30 rounded-full font-bold">
|
| 100 |
+
ACTIVE
|
| 101 |
+
</span>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Section 3: Connection Instructions */}
|
| 107 |
+
<div className="glass-panel border-slate-900 rounded-2xl p-6 flex flex-col gap-4">
|
| 108 |
+
<div className="flex items-center gap-2 text-slate-400 font-semibold text-xs uppercase tracking-wider">
|
| 109 |
+
<Shield size={14} />
|
| 110 |
+
<span>Production Setup Checklist</span>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="flex flex-col gap-3 text-xs leading-relaxed text-slate-300">
|
| 113 |
+
<div className="flex gap-2.5 items-start">
|
| 114 |
+
<CheckCircle2 size={15} className="text-purple-400 mt-0.5 shrink-0" />
|
| 115 |
+
<p>Register a Google Developer project, enable Gmail API and OAuth client credentials.</p>
|
| 116 |
+
</div>
|
| 117 |
+
<div className="flex gap-2.5 items-start">
|
| 118 |
+
<CheckCircle2 size={15} className="text-purple-400 mt-0.5 shrink-0" />
|
| 119 |
+
<p>Set <code>MOCK_EMAIL_PROVIDER=false</code> inside the <code>.env</code> file.</p>
|
| 120 |
+
</div>
|
| 121 |
+
<div className="flex gap-2.5 items-start">
|
| 122 |
+
<CheckCircle2 size={15} className="text-purple-400 mt-0.5 shrink-0" />
|
| 123 |
+
<p>Ensure a valid <code>OPENAI_API_KEY</code> is specified to enable AI models.</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<button
|
| 129 |
+
onClick={handleLogout}
|
| 130 |
+
className="w-full bg-red-950/20 hover:bg-red-950/30 border border-red-950 text-red-400 hover:text-red-300 font-medium py-3 rounded-xl text-xs transition cursor-pointer"
|
| 131 |
+
>
|
| 132 |
+
Log Out Account
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
frontend/app/(auth)/login/page.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, Suspense } from 'react';
|
| 4 |
+
import { useRouter, useSearchParams } from 'next/navigation';
|
| 5 |
+
import { Mail, Lock, Shield, Sparkles, UserPlus, LogIn, Globe } from 'lucide-react';
|
| 6 |
+
import { API_BASE_URL } from '@/app/config';
|
| 7 |
+
|
| 8 |
+
function LoginContent() {
|
| 9 |
+
const router = useRouter();
|
| 10 |
+
const searchParams = useSearchParams();
|
| 11 |
+
|
| 12 |
+
const [isRegisterMode, setIsRegisterMode] = useState(false);
|
| 13 |
+
const [email, setEmail] = useState('');
|
| 14 |
+
const [password, setPassword] = useState('');
|
| 15 |
+
const [name, setName] = useState('');
|
| 16 |
+
const [loading, setLoading] = useState(false);
|
| 17 |
+
const [message, setMessage] = useState({ text: '', type: 'idle' });
|
| 18 |
+
|
| 19 |
+
// Handle redirect from OAuth callback with query parameters
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const token = searchParams.get('token');
|
| 22 |
+
const userEmail = searchParams.get('email');
|
| 23 |
+
const userId = searchParams.get('userId');
|
| 24 |
+
const error = searchParams.get('error');
|
| 25 |
+
|
| 26 |
+
if (token && userEmail && userId) {
|
| 27 |
+
localStorage.setItem('token', token);
|
| 28 |
+
localStorage.setItem('email', userEmail);
|
| 29 |
+
localStorage.setItem('userId', userId);
|
| 30 |
+
|
| 31 |
+
setMessage({ text: 'OAuth login successful! Redirecting...', type: 'success' });
|
| 32 |
+
setTimeout(() => {
|
| 33 |
+
router.push('/inbox');
|
| 34 |
+
}, 1000);
|
| 35 |
+
} else if (error) {
|
| 36 |
+
setMessage({ text: 'Google OAuth authentication failed. Please try again.', type: 'error' });
|
| 37 |
+
}
|
| 38 |
+
}, [searchParams, router]);
|
| 39 |
+
|
| 40 |
+
const handleOAuthLogin = async () => {
|
| 41 |
+
setLoading(true);
|
| 42 |
+
try {
|
| 43 |
+
const response = await fetch(`${API_BASE_URL}/api/auth/google-login`);
|
| 44 |
+
const data = await response.json();
|
| 45 |
+
if (data.url) {
|
| 46 |
+
window.location.href = data.url;
|
| 47 |
+
} else {
|
| 48 |
+
throw new Error('Authentication endpoint failed to return redirect URL');
|
| 49 |
+
}
|
| 50 |
+
} catch (err: any) {
|
| 51 |
+
console.error(err);
|
| 52 |
+
setMessage({ text: err.message || 'OAuth initialization failed', type: 'error' });
|
| 53 |
+
setLoading(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const handleCredentialsSubmit = async (e: React.FormEvent) => {
|
| 58 |
+
e.preventDefault();
|
| 59 |
+
if (!email || !password) return;
|
| 60 |
+
|
| 61 |
+
setLoading(true);
|
| 62 |
+
setMessage({ text: '', type: 'idle' });
|
| 63 |
+
|
| 64 |
+
const endpoint = isRegisterMode
|
| 65 |
+
? `${API_BASE_URL}/api/auth/register`
|
| 66 |
+
: `${API_BASE_URL}/api/auth/login`;
|
| 67 |
+
|
| 68 |
+
const payload = isRegisterMode
|
| 69 |
+
? JSON.stringify({ email, password, name })
|
| 70 |
+
: new URLSearchParams({ username: email, password: password });
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const response = await fetch(endpoint, {
|
| 74 |
+
method: 'POST',
|
| 75 |
+
headers: isRegisterMode
|
| 76 |
+
? { 'Content-Type': 'application/json' }
|
| 77 |
+
: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 78 |
+
body: payload,
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const data = await response.json();
|
| 82 |
+
|
| 83 |
+
if (!response.ok) {
|
| 84 |
+
throw new Error(data.detail || 'Authentication failed');
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Store in localStorage
|
| 88 |
+
localStorage.setItem('token', data.access_token);
|
| 89 |
+
localStorage.setItem('email', data.email);
|
| 90 |
+
localStorage.setItem('userId', data.user_id);
|
| 91 |
+
|
| 92 |
+
setMessage({ text: isRegisterMode ? 'Account registered successfully!' : 'Login successful!', type: 'success' });
|
| 93 |
+
setTimeout(() => {
|
| 94 |
+
router.push('/inbox');
|
| 95 |
+
}, 800);
|
| 96 |
+
} catch (err: any) {
|
| 97 |
+
console.error(err);
|
| 98 |
+
setMessage({ text: err.message || 'Server connection error', type: 'error' });
|
| 99 |
+
} finally {
|
| 100 |
+
setLoading(false);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div className="min-h-screen bg-[#06060c] flex items-center justify-center relative px-4 overflow-hidden">
|
| 106 |
+
{/* Decorative background glows */}
|
| 107 |
+
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-900/15 rounded-full blur-[120px] pointer-events-none" />
|
| 108 |
+
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-cyan-900/10 rounded-full blur-[120px] pointer-events-none" />
|
| 109 |
+
|
| 110 |
+
{/* Main Glass Card Container */}
|
| 111 |
+
<div className="w-full max-w-md glass-panel rounded-2xl border border-purple-500/10 p-8 flex flex-col gap-6 animate-fade-in-up relative z-10">
|
| 112 |
+
{/* Brand Header */}
|
| 113 |
+
<div className="text-center">
|
| 114 |
+
<div className="inline-flex h-11 w-11 items-center justify-center bg-purple-950/60 border border-purple-500/20 text-purple-400 rounded-xl mb-3 shadow-[0_0_15px_rgba(147,51,234,0.15)]">
|
| 115 |
+
<Shield size={20} className="fill-purple-400/10" />
|
| 116 |
+
</div>
|
| 117 |
+
<h1 className="text-2xl font-bold tracking-tight text-white flex items-center justify-center gap-1.5 leading-snug">
|
| 118 |
+
<span>Antigravity Mail</span>
|
| 119 |
+
<Sparkles size={18} className="text-purple-400" />
|
| 120 |
+
</h1>
|
| 121 |
+
<p className="text-xs text-slate-500 mt-1 font-medium">Agentic AI Email & Triage Assistant</p>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Status Message */}
|
| 125 |
+
{message.type !== 'idle' && (
|
| 126 |
+
<div className={`p-3.5 rounded-xl border text-xs font-medium text-center ${
|
| 127 |
+
message.type === 'success'
|
| 128 |
+
? 'bg-emerald-950/20 border-emerald-500/25 text-emerald-400'
|
| 129 |
+
: 'bg-red-950/20 border-red-500/25 text-red-400'
|
| 130 |
+
}`}>
|
| 131 |
+
{message.text}
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{/* Credentials Form */}
|
| 136 |
+
<form onSubmit={handleCredentialsSubmit} className="flex flex-col gap-4">
|
| 137 |
+
{isRegisterMode && (
|
| 138 |
+
<div className="flex flex-col gap-1.5">
|
| 139 |
+
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400">Full Name</label>
|
| 140 |
+
<div className="relative">
|
| 141 |
+
<input
|
| 142 |
+
type="text"
|
| 143 |
+
required
|
| 144 |
+
placeholder="John Doe"
|
| 145 |
+
value={name}
|
| 146 |
+
onChange={(e) => setName(e.target.value)}
|
| 147 |
+
className="w-full px-4 py-2.5 rounded-xl text-sm glass-input"
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
|
| 153 |
+
<div className="flex flex-col gap-1.5">
|
| 154 |
+
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400">Email Address</label>
|
| 155 |
+
<div className="relative">
|
| 156 |
+
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
| 157 |
+
<input
|
| 158 |
+
type="email"
|
| 159 |
+
required
|
| 160 |
+
placeholder="developer@example.com"
|
| 161 |
+
value={email}
|
| 162 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 163 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-sm glass-input"
|
| 164 |
+
/>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div className="flex flex-col gap-1.5">
|
| 169 |
+
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400">Password</label>
|
| 170 |
+
<div className="relative">
|
| 171 |
+
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
| 172 |
+
<input
|
| 173 |
+
type="password"
|
| 174 |
+
required
|
| 175 |
+
placeholder="••••••••"
|
| 176 |
+
value={password}
|
| 177 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 178 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-sm glass-input"
|
| 179 |
+
/>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<button
|
| 184 |
+
type="submit"
|
| 185 |
+
disabled={loading}
|
| 186 |
+
className="glow-btn text-white w-full py-2.5 rounded-xl font-medium text-sm flex items-center justify-center gap-2 mt-2 cursor-pointer disabled:opacity-50"
|
| 187 |
+
>
|
| 188 |
+
{isRegisterMode ? <UserPlus size={16} /> : <LogIn size={16} />}
|
| 189 |
+
<span>{loading ? 'Processing...' : isRegisterMode ? 'Register Account' : 'Sign In'}</span>
|
| 190 |
+
</button>
|
| 191 |
+
</form>
|
| 192 |
+
|
| 193 |
+
{/* Separator */}
|
| 194 |
+
<div className="flex items-center gap-3 text-[10px] text-slate-600 uppercase font-bold tracking-widest my-1">
|
| 195 |
+
<div className="flex-1 h-px bg-slate-900" />
|
| 196 |
+
<span>or</span>
|
| 197 |
+
<div className="flex-1 h-px bg-slate-900" />
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
{/* Google OAuth Access */}
|
| 201 |
+
<button
|
| 202 |
+
onClick={handleOAuthLogin}
|
| 203 |
+
disabled={loading}
|
| 204 |
+
className="w-full bg-slate-950/50 hover:bg-slate-900 text-slate-200 border border-slate-800/80 hover:border-slate-700 py-2.5 rounded-xl text-sm font-medium flex items-center justify-center gap-2.5 transition cursor-pointer disabled:opacity-50"
|
| 205 |
+
>
|
| 206 |
+
<Globe size={16} className="text-purple-400" />
|
| 207 |
+
<span>Continue with Google Gmail</span>
|
| 208 |
+
</button>
|
| 209 |
+
|
| 210 |
+
{/* Registration Toggle */}
|
| 211 |
+
<div className="text-center text-xs text-slate-500 mt-2">
|
| 212 |
+
{isRegisterMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
| 213 |
+
<button
|
| 214 |
+
onClick={() => {
|
| 215 |
+
setIsRegisterMode(!isRegisterMode);
|
| 216 |
+
setMessage({ text: '', type: 'idle' });
|
| 217 |
+
}}
|
| 218 |
+
className="text-purple-400 hover:text-purple-300 font-semibold cursor-pointer outline-none bg-transparent border-none ml-0.5"
|
| 219 |
+
>
|
| 220 |
+
{isRegisterMode ? 'Sign In' : 'Create an Account'}
|
| 221 |
+
</button>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export default function LoginPage() {
|
| 229 |
+
return (
|
| 230 |
+
<Suspense fallback={
|
| 231 |
+
<div className="min-h-screen bg-[#06060c] flex items-center justify-center text-slate-500">
|
| 232 |
+
<div className="flex flex-col items-center gap-2">
|
| 233 |
+
<div className="h-6 w-6 animate-spin rounded-full border border-purple-500 border-t-transparent" />
|
| 234 |
+
<p className="text-xs">Loading page dependencies...</p>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
}>
|
| 238 |
+
<LoginContent />
|
| 239 |
+
</Suspense>
|
| 240 |
+
);
|
| 241 |
+
}
|
frontend/app/config.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 2 |
+
export const WS_BASE_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000';
|
frontend/app/favicon.ico
ADDED
|
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@theme inline {
|
| 4 |
+
--color-brand-primary: hsl(263, 90%, 55%);
|
| 5 |
+
--color-brand-secondary: hsl(190, 95%, 45%);
|
| 6 |
+
--color-brand-glow: hsl(263, 90%, 75%);
|
| 7 |
+
--color-glass-bg: rgba(13, 13, 24, 0.45);
|
| 8 |
+
--color-glass-border: rgba(255, 255, 255, 0.08);
|
| 9 |
+
--color-glass-border-glow: rgba(147, 51, 234, 0.35);
|
| 10 |
+
--color-inbox-bg: #09090e;
|
| 11 |
+
--color-card-dark: #12121e;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
:root {
|
| 15 |
+
--background: #06060c;
|
| 16 |
+
--foreground: #f3f4f6;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
background-color: var(--background);
|
| 21 |
+
color: var(--foreground);
|
| 22 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 23 |
+
overflow-x: hidden;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* Glassmorphism utility classes */
|
| 27 |
+
.glass-panel {
|
| 28 |
+
background: rgba(15, 15, 27, 0.65);
|
| 29 |
+
backdrop-filter: blur(16px);
|
| 30 |
+
-webkit-backdrop-filter: blur(16px);
|
| 31 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 32 |
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.glass-panel-hover:hover {
|
| 36 |
+
border-color: rgba(147, 51, 234, 0.25);
|
| 37 |
+
box-shadow: 0 8px 32px 0 rgba(147, 51, 234, 0.1);
|
| 38 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.glass-input {
|
| 42 |
+
background: rgba(255, 255, 255, 0.03);
|
| 43 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 44 |
+
color: #fff;
|
| 45 |
+
transition: all 0.2s ease;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.glass-input:focus {
|
| 49 |
+
background: rgba(255, 255, 255, 0.05);
|
| 50 |
+
border-color: #9333ea;
|
| 51 |
+
box-shadow: 0 0 10px rgba(147, 51, 234, 0.2);
|
| 52 |
+
outline: none;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Glowing text and states */
|
| 56 |
+
.glow-text {
|
| 57 |
+
text-shadow: 0 0 15px rgba(147, 51, 234, 0.6);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.glow-btn {
|
| 61 |
+
background: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
|
| 62 |
+
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3);
|
| 63 |
+
transition: all 0.3s ease;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.glow-btn:hover {
|
| 67 |
+
transform: translateY(-1px);
|
| 68 |
+
box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5), 0 0 10px rgba(6, 182, 212, 0.3);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* Custom Scrollbars */
|
| 72 |
+
::-webkit-scrollbar {
|
| 73 |
+
width: 6px;
|
| 74 |
+
height: 6px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
::-webkit-scrollbar-track {
|
| 78 |
+
background: rgba(15, 15, 27, 0.4);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
::-webkit-scrollbar-thumb {
|
| 82 |
+
background: rgba(255, 255, 255, 0.1);
|
| 83 |
+
border-radius: 4px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
::-webkit-scrollbar-thumb:hover {
|
| 87 |
+
background: rgba(147, 51, 234, 0.4);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Pulse animation for active agents */
|
| 91 |
+
@keyframes agent-glow {
|
| 92 |
+
0%, 100% {
|
| 93 |
+
box-shadow: 0 0 8px rgba(147, 51, 234, 0.4);
|
| 94 |
+
border-color: rgba(147, 51, 234, 0.4);
|
| 95 |
+
}
|
| 96 |
+
50% {
|
| 97 |
+
box-shadow: 0 0 20px rgba(147, 51, 234, 0.8), 0 0 10px rgba(6, 182, 212, 0.4);
|
| 98 |
+
border-color: rgba(6, 182, 212, 0.6);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.animate-agent-glow {
|
| 103 |
+
animation: agent-glow 3s infinite ease-in-out;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Custom animations */
|
| 107 |
+
@keyframes fade-in-up {
|
| 108 |
+
from {
|
| 109 |
+
opacity: 0;
|
| 110 |
+
transform: translateY(12px);
|
| 111 |
+
}
|
| 112 |
+
to {
|
| 113 |
+
opacity: 1;
|
| 114 |
+
transform: translateY(0);
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.animate-fade-in-up {
|
| 119 |
+
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 120 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Antigravity Mail - AI Email Agent",
|
| 17 |
+
description: "Agentic AI Email and Inbox Triage Assistant",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html
|
| 27 |
+
lang="en"
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
+
>
|
| 30 |
+
<body className="min-h-full flex flex-col">{children}</body>
|
| 31 |
+
</html>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
|
| 6 |
+
export default function Home() {
|
| 7 |
+
const router = useRouter();
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
const token = localStorage.getItem('token');
|
| 11 |
+
if (token) {
|
| 12 |
+
router.push('/inbox');
|
| 13 |
+
} else {
|
| 14 |
+
router.push('/login');
|
| 15 |
+
}
|
| 16 |
+
}, [router]);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="min-h-screen bg-[#06060c] flex items-center justify-center text-slate-500">
|
| 20 |
+
<div className="flex flex-col items-center gap-2">
|
| 21 |
+
<div className="h-6 w-6 animate-spin rounded-full border border-purple-500 border-t-transparent" />
|
| 22 |
+
<p className="text-xs">Redirecting to workspace...</p>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
}
|
frontend/components/AgentRunTimeline.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Mail, Search, Sparkles, Database, ShieldCheck, Terminal, AlertCircle } from 'lucide-react';
|
| 3 |
+
import { API_BASE_URL } from '@/app/config';
|
| 4 |
+
|
| 5 |
+
interface AgentLogItem {
|
| 6 |
+
step: number;
|
| 7 |
+
thought: string;
|
| 8 |
+
action: string;
|
| 9 |
+
result: string;
|
| 10 |
+
timestamp: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface AgentRunTimelineProps {
|
| 14 |
+
runId: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function AgentRunTimeline({ runId }: AgentRunTimelineProps) {
|
| 18 |
+
const [logs, setLogs] = useState<AgentLogItem[]>([]);
|
| 19 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 20 |
+
const [error, setError] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const fetchLogs = async () => {
|
| 24 |
+
setIsLoading(true);
|
| 25 |
+
setError(false);
|
| 26 |
+
|
| 27 |
+
const token = localStorage.getItem('token') || '';
|
| 28 |
+
try {
|
| 29 |
+
const response = await fetch(`${API_BASE_URL}/api/agent/runs/${runId}/logs`, {
|
| 30 |
+
headers: {
|
| 31 |
+
'Authorization': `Bearer ${token}`,
|
| 32 |
+
},
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
if (!response.ok) {
|
| 36 |
+
throw new Error('Failed to fetch run logs');
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const data = await response.json();
|
| 40 |
+
setLogs(data);
|
| 41 |
+
} catch (err) {
|
| 42 |
+
console.error(err);
|
| 43 |
+
setError(true);
|
| 44 |
+
} finally {
|
| 45 |
+
setIsLoading(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
if (runId) {
|
| 50 |
+
fetchLogs();
|
| 51 |
+
// Poll every 3 seconds while active
|
| 52 |
+
const interval = setInterval(fetchLogs, 3000);
|
| 53 |
+
return () => clearInterval(interval);
|
| 54 |
+
}
|
| 55 |
+
}, [runId]);
|
| 56 |
+
|
| 57 |
+
const getActionIcon = (action: string) => {
|
| 58 |
+
switch (action.toUpperCase()) {
|
| 59 |
+
case 'READ_EMAIL':
|
| 60 |
+
return <Mail size={14} className="text-cyan-400" />;
|
| 61 |
+
case 'RETRIEVE_CONTEXT':
|
| 62 |
+
return <Database size={14} className="text-indigo-400" />;
|
| 63 |
+
case 'LLM_TRIAGE':
|
| 64 |
+
return <Sparkles size={14} className="text-purple-400" />;
|
| 65 |
+
case 'COMMIT_CHANGES':
|
| 66 |
+
return <ShieldCheck size={14} className="text-emerald-400" />;
|
| 67 |
+
default:
|
| 68 |
+
return <Terminal size={14} className="text-slate-400" />;
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const getActionColor = (action: string) => {
|
| 73 |
+
switch (action.toUpperCase()) {
|
| 74 |
+
case 'READ_EMAIL':
|
| 75 |
+
return 'border-cyan-500/30 bg-cyan-950/20';
|
| 76 |
+
case 'RETRIEVE_CONTEXT':
|
| 77 |
+
return 'border-indigo-500/30 bg-indigo-950/20';
|
| 78 |
+
case 'LLM_TRIAGE':
|
| 79 |
+
return 'border-purple-500/30 bg-purple-950/20';
|
| 80 |
+
case 'COMMIT_CHANGES':
|
| 81 |
+
return 'border-emerald-500/30 bg-emerald-950/20';
|
| 82 |
+
default:
|
| 83 |
+
return 'border-slate-800 bg-slate-950/40';
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
if (isLoading && logs.length === 0) {
|
| 88 |
+
return (
|
| 89 |
+
<div className="flex items-center justify-center p-8 text-slate-500">
|
| 90 |
+
<div className="flex items-center gap-2">
|
| 91 |
+
<div className="h-4 w-4 animate-spin rounded-full border border-purple-500 border-t-transparent" />
|
| 92 |
+
<span className="text-xs">Loading audit trail...</span>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (error && logs.length === 0) {
|
| 99 |
+
return (
|
| 100 |
+
<div className="flex items-center justify-center p-6 text-red-400 gap-2 border border-red-950/30 bg-red-950/10 rounded-xl">
|
| 101 |
+
<AlertCircle size={16} />
|
| 102 |
+
<span className="text-xs">Could not fetch logs for this agent execution.</span>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="flex flex-col gap-4">
|
| 109 |
+
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
| 110 |
+
<Terminal size={14} />
|
| 111 |
+
<span>Agent Thinking Audit Trail</span>
|
| 112 |
+
</h4>
|
| 113 |
+
|
| 114 |
+
{logs.length === 0 ? (
|
| 115 |
+
<p className="text-xs text-slate-500 italic">No agent log steps found for this execution run.</p>
|
| 116 |
+
) : (
|
| 117 |
+
<div className="relative border-l border-slate-800 ml-3.5 pl-6 flex flex-col gap-6.5">
|
| 118 |
+
{logs.map((log) => (
|
| 119 |
+
<div key={log.step} className="relative group">
|
| 120 |
+
{/* Vertical line connector bullet */}
|
| 121 |
+
<div className={`absolute -left-[35px] top-0 h-7 w-7 rounded-full border flex items-center justify-center transition-transform group-hover:scale-110 ${getActionColor(log.action)}`}>
|
| 122 |
+
{getActionIcon(log.action)}
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* Node Contents */}
|
| 126 |
+
<div>
|
| 127 |
+
<div className="flex items-center gap-2.5 mb-1">
|
| 128 |
+
<span className="text-xs font-semibold text-slate-200">
|
| 129 |
+
Step {log.step}: {log.thought}
|
| 130 |
+
</span>
|
| 131 |
+
<span className="text-[10px] text-slate-500 font-mono">
|
| 132 |
+
{new Date(log.timestamp).toLocaleTimeString()}
|
| 133 |
+
</span>
|
| 134 |
+
</div>
|
| 135 |
+
<div className="bg-slate-950/35 border border-slate-900/60 p-3 rounded-xl text-[11px] text-slate-400 font-mono leading-relaxed max-w-full overflow-x-auto">
|
| 136 |
+
<span className="text-slate-500 block mb-1">Action: {log.action}</span>
|
| 137 |
+
<span className="text-slate-300 block">{log.result}</span>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
))}
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
}
|
frontend/components/AgentStatusBar.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Cpu, RefreshCw, Terminal, Zap } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface AgentStatus {
|
| 5 |
+
status: string;
|
| 6 |
+
progress: number;
|
| 7 |
+
active: boolean;
|
| 8 |
+
runId?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface AgentStatusBarProps {
|
| 12 |
+
statusData: AgentStatus;
|
| 13 |
+
isSyncing: boolean;
|
| 14 |
+
onTriggerSync: () => Promise<void>;
|
| 15 |
+
onViewLogs: (runId: string) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function AgentStatusBar({
|
| 19 |
+
statusData,
|
| 20 |
+
isSyncing,
|
| 21 |
+
onTriggerSync,
|
| 22 |
+
onViewLogs,
|
| 23 |
+
}: AgentStatusBarProps) {
|
| 24 |
+
return (
|
| 25 |
+
<div className="glass-panel rounded-2xl p-6 flex flex-col gap-5 border border-purple-500/10">
|
| 26 |
+
{/* Header / Active Indicator */}
|
| 27 |
+
<div className="flex items-center justify-between">
|
| 28 |
+
<div className="flex items-center gap-3">
|
| 29 |
+
<div className="relative flex h-3 w-3">
|
| 30 |
+
{statusData.active ? (
|
| 31 |
+
<>
|
| 32 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span>
|
| 33 |
+
<span className="relative inline-flex rounded-full h-3 w-3 bg-purple-500"></span>
|
| 34 |
+
</>
|
| 35 |
+
) : (
|
| 36 |
+
<span className="relative inline-flex rounded-full h-3 w-3 bg-slate-600"></span>
|
| 37 |
+
)}
|
| 38 |
+
</div>
|
| 39 |
+
<div>
|
| 40 |
+
<h3 className="font-semibold text-sm tracking-wide text-slate-200">AGENT STATUS</h3>
|
| 41 |
+
<p className="text-xs text-slate-400">GPT-4o-Mini Engine</p>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div className="flex items-center gap-1 bg-purple-950/40 px-2.5 py-1 rounded-full border border-purple-800/30 text-purple-300 text-xs">
|
| 45 |
+
<Zap size={12} className="fill-purple-300/20" />
|
| 46 |
+
<span>Active</span>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Status Bar Indicator */}
|
| 51 |
+
<div className={`p-4 rounded-xl border ${statusData.active ? 'bg-purple-950/20 border-purple-500/25 animate-agent-glow' : 'bg-slate-950/40 border-slate-800/40'}`}>
|
| 52 |
+
<p className="text-sm font-medium text-slate-100 truncate mb-2">
|
| 53 |
+
{statusData.status || "Agent is sleeping. Waiting for mail..."}
|
| 54 |
+
</p>
|
| 55 |
+
|
| 56 |
+
{/* Progress Bar */}
|
| 57 |
+
<div className="w-full bg-slate-900 rounded-full h-2 overflow-hidden">
|
| 58 |
+
<div
|
| 59 |
+
className="h-full bg-gradient-to-r from-purple-500 to-cyan-500 transition-all duration-500 ease-out"
|
| 60 |
+
style={{ width: `${statusData.progress}%` }}
|
| 61 |
+
/>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="flex justify-between items-center mt-2 text-xs text-slate-400">
|
| 65 |
+
<span>Progress</span>
|
| 66 |
+
<span className="font-mono">{statusData.progress}%</span>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
{/* System Metrics */}
|
| 71 |
+
<div className="grid grid-cols-2 gap-3 text-xs">
|
| 72 |
+
<div className="bg-slate-950/30 border border-slate-900 p-3 rounded-lg">
|
| 73 |
+
<span className="text-slate-500 block mb-1">Worker Threads</span>
|
| 74 |
+
<span className="font-mono text-slate-300 font-medium">Celery: Active</span>
|
| 75 |
+
</div>
|
| 76 |
+
<div className="bg-slate-950/30 border border-slate-900 p-3 rounded-lg">
|
| 77 |
+
<span className="text-slate-500 block mb-1">RAG Context</span>
|
| 78 |
+
<span className="font-mono text-slate-300 font-medium">MongoDB: Live</span>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Quick Action Trigger */}
|
| 83 |
+
<div className="flex flex-col gap-2.5 mt-2">
|
| 84 |
+
<button
|
| 85 |
+
onClick={onTriggerSync}
|
| 86 |
+
disabled={isSyncing || statusData.active}
|
| 87 |
+
className="glow-btn text-white w-full font-medium py-2.5 px-4 rounded-xl flex items-center justify-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
| 88 |
+
>
|
| 89 |
+
<RefreshCw size={15} className={`transition-transform ${isSyncing ? 'animate-spin' : ''}`} />
|
| 90 |
+
<span>{isSyncing ? 'Syncing Mailbox...' : 'Sync Mailbox Now'}</span>
|
| 91 |
+
</button>
|
| 92 |
+
|
| 93 |
+
{statusData.runId && (
|
| 94 |
+
<button
|
| 95 |
+
onClick={() => onViewLogs(statusData.runId!)}
|
| 96 |
+
className="w-full bg-slate-950/50 hover:bg-slate-900 border border-slate-800 text-slate-300 hover:text-slate-100 transition py-2 px-4 rounded-xl flex items-center justify-center gap-2 text-sm cursor-pointer"
|
| 97 |
+
>
|
| 98 |
+
<Terminal size={14} />
|
| 99 |
+
<span>Audit Agent Logs</span>
|
| 100 |
+
</button>
|
| 101 |
+
)}
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
frontend/components/EmailDetail.tsx
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Eye, FileText, CheckSquare, Sparkles, User, Calendar, Paperclip } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface EmailDetailData {
|
| 5 |
+
id: string;
|
| 6 |
+
subject?: string;
|
| 7 |
+
sender: string;
|
| 8 |
+
received_at: string;
|
| 9 |
+
category: string;
|
| 10 |
+
is_read: boolean;
|
| 11 |
+
is_starred: boolean;
|
| 12 |
+
thread_id?: string;
|
| 13 |
+
has_attachment: boolean;
|
| 14 |
+
body_html: string;
|
| 15 |
+
body_text: string;
|
| 16 |
+
summary?: string;
|
| 17 |
+
action_items: string[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface EmailDetailProps {
|
| 21 |
+
email: EmailDetailData;
|
| 22 |
+
isLoading: boolean;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function EmailDetail({ email, isLoading }: EmailDetailProps) {
|
| 26 |
+
const [activeTab, setActiveTab] = useState<'html' | 'text'>('html');
|
| 27 |
+
const [checkedItems, setCheckedItems] = useState<Record<number, boolean>>({});
|
| 28 |
+
|
| 29 |
+
// Reset checked items when selected email changes
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
setCheckedItems({});
|
| 32 |
+
if (email.body_html) {
|
| 33 |
+
setActiveTab('html');
|
| 34 |
+
} else {
|
| 35 |
+
setActiveTab('text');
|
| 36 |
+
}
|
| 37 |
+
}, [email.id, email.body_html]);
|
| 38 |
+
|
| 39 |
+
const toggleCheck = (idx: number) => {
|
| 40 |
+
setCheckedItems((prev) => ({
|
| 41 |
+
...prev,
|
| 42 |
+
[idx]: !prev[idx],
|
| 43 |
+
}));
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const getCategoryStyle = (cat: string) => {
|
| 47 |
+
switch (cat.toLowerCase()) {
|
| 48 |
+
case 'urgent':
|
| 49 |
+
return 'bg-red-950/40 border border-red-500/30 text-red-400';
|
| 50 |
+
case 'invoice':
|
| 51 |
+
return 'bg-cyan-950/40 border border-cyan-500/30 text-cyan-400';
|
| 52 |
+
case 'newsletter':
|
| 53 |
+
return 'bg-slate-900 border border-slate-700/60 text-slate-400';
|
| 54 |
+
default:
|
| 55 |
+
return 'bg-emerald-950/40 border border-emerald-500/30 text-emerald-400';
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
if (isLoading) {
|
| 60 |
+
return (
|
| 61 |
+
<div className="flex items-center justify-center h-full text-slate-500">
|
| 62 |
+
<div className="flex flex-col items-center gap-3">
|
| 63 |
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-purple-500 border-t-transparent" />
|
| 64 |
+
<p className="text-xs">Loading email content...</p>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="flex flex-col h-full bg-slate-950/30 border border-slate-900 rounded-2xl overflow-hidden animate-fade-in-up">
|
| 72 |
+
{/* Subject Header */}
|
| 73 |
+
<div className="p-6 border-b border-slate-900 bg-slate-950/20">
|
| 74 |
+
<div className="flex items-center gap-3 mb-2.5">
|
| 75 |
+
<span className={`px-2.5 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider ${getCategoryStyle(email.category)}`}>
|
| 76 |
+
{email.category}
|
| 77 |
+
</span>
|
| 78 |
+
<span className="text-xs text-slate-500 flex items-center gap-1">
|
| 79 |
+
<Calendar size={12} />
|
| 80 |
+
{new Date(email.received_at).toLocaleString()}
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
<h2 className="text-xl font-bold text-white tracking-tight leading-snug">
|
| 84 |
+
{email.subject || '(No Subject)'}
|
| 85 |
+
</h2>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Sender Header */}
|
| 89 |
+
<div className="px-6 py-4 border-b border-slate-900/60 flex items-center gap-3 bg-slate-950/10">
|
| 90 |
+
<div className="h-9 w-9 rounded-full bg-purple-950/50 border border-purple-800/40 flex items-center justify-center text-purple-400 font-bold text-sm">
|
| 91 |
+
<User size={16} />
|
| 92 |
+
</div>
|
| 93 |
+
<div className="min-w-0">
|
| 94 |
+
<span className="text-sm font-semibold text-slate-200 block truncate">
|
| 95 |
+
{email.sender}
|
| 96 |
+
</span>
|
| 97 |
+
<span className="text-[11px] text-slate-500 block">
|
| 98 |
+
to me
|
| 99 |
+
</span>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
{/* Main Grid: Body on Left, AI Panel on Right */}
|
| 104 |
+
<div className="flex-1 flex overflow-hidden">
|
| 105 |
+
{/* Left Side: Email Body Content */}
|
| 106 |
+
<div className="flex-1 flex flex-col border-r border-slate-900 overflow-hidden">
|
| 107 |
+
{/* Content Format Toggle Tabs */}
|
| 108 |
+
<div className="flex px-4 py-2 gap-2 border-b border-slate-900/60 bg-slate-950/15 text-xs justify-end">
|
| 109 |
+
<button
|
| 110 |
+
onClick={() => setActiveTab('html')}
|
| 111 |
+
disabled={!email.body_html}
|
| 112 |
+
className={`px-2.5 py-1 rounded flex items-center gap-1 cursor-pointer transition ${
|
| 113 |
+
activeTab === 'html' ? 'bg-slate-800 text-white' : 'text-slate-500 hover:text-slate-300'
|
| 114 |
+
}`}
|
| 115 |
+
>
|
| 116 |
+
<Eye size={12} />
|
| 117 |
+
<span>HTML View</span>
|
| 118 |
+
</button>
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => setActiveTab('text')}
|
| 121 |
+
className={`px-2.5 py-1 rounded flex items-center gap-1 cursor-pointer transition ${
|
| 122 |
+
activeTab === 'text' ? 'bg-slate-800 text-white' : 'text-slate-500 hover:text-slate-300'
|
| 123 |
+
}`}
|
| 124 |
+
>
|
| 125 |
+
<FileText size={12} />
|
| 126 |
+
<span>Plain Text</span>
|
| 127 |
+
</button>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* Body Viewer */}
|
| 131 |
+
<div className="flex-1 p-6 overflow-y-auto bg-slate-950/10">
|
| 132 |
+
{activeTab === 'html' && email.body_html ? (
|
| 133 |
+
<div
|
| 134 |
+
className="prose prose-invert max-w-none text-slate-300 text-sm leading-relaxed"
|
| 135 |
+
dangerouslySetInnerHTML={{ __html: email.body_html }}
|
| 136 |
+
/>
|
| 137 |
+
) : (
|
| 138 |
+
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-300 leading-relaxed">
|
| 139 |
+
{email.body_text || "No email body text content found."}
|
| 140 |
+
</pre>
|
| 141 |
+
)}
|
| 142 |
+
|
| 143 |
+
{/* Attachments Section */}
|
| 144 |
+
{email.has_attachment && (
|
| 145 |
+
<div className="mt-8 pt-6 border-t border-slate-900">
|
| 146 |
+
<h5 className="text-xs font-semibold text-slate-400 mb-3 flex items-center gap-1.5">
|
| 147 |
+
<Paperclip size={12} />
|
| 148 |
+
<span>Attachments (1)</span>
|
| 149 |
+
</h5>
|
| 150 |
+
<div className="inline-flex items-center gap-3 bg-slate-950/40 hover:bg-slate-900 border border-slate-800 p-2.5 rounded-xl transition cursor-pointer">
|
| 151 |
+
<div className="p-2 bg-purple-950/50 text-purple-400 rounded-lg">
|
| 152 |
+
<FileText size={16} />
|
| 153 |
+
</div>
|
| 154 |
+
<div>
|
| 155 |
+
<span className="text-xs font-medium text-slate-300 block">document.pdf</span>
|
| 156 |
+
<span className="text-[10px] text-slate-500 block">100 KB • PDF File</span>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{/* Right Side: AI Panel (TL;DR & Action Items) */}
|
| 165 |
+
<div className="w-80 flex flex-col bg-slate-950/20 overflow-y-auto p-5 gap-5">
|
| 166 |
+
{/* Summary Box */}
|
| 167 |
+
<div className="glass-panel border-purple-500/10 rounded-xl p-4 flex flex-col gap-2.5">
|
| 168 |
+
<div className="flex items-center gap-2 text-purple-400 font-semibold text-xs uppercase tracking-wider">
|
| 169 |
+
<Sparkles size={14} className="fill-purple-400/20" />
|
| 170 |
+
<span>AI TL;DR Summary</span>
|
| 171 |
+
</div>
|
| 172 |
+
<p className="text-xs text-slate-300 leading-relaxed">
|
| 173 |
+
{email.summary || "Agent hasn't generated a summary yet. Wait for triage logs to update."}
|
| 174 |
+
</p>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
{/* Action Items Checklist */}
|
| 178 |
+
<div className="glass-panel border-purple-500/10 rounded-xl p-4 flex flex-col gap-3">
|
| 179 |
+
<div className="flex items-center gap-2 text-cyan-400 font-semibold text-xs uppercase tracking-wider">
|
| 180 |
+
<CheckSquare size={14} />
|
| 181 |
+
<span>Task Checklist ({email.action_items.length})</span>
|
| 182 |
+
</div>
|
| 183 |
+
{email.action_items.length === 0 ? (
|
| 184 |
+
<p className="text-xs text-slate-500 italic">No tasks extracted.</p>
|
| 185 |
+
) : (
|
| 186 |
+
<div className="flex flex-col gap-2.5">
|
| 187 |
+
{email.action_items.map((item, idx) => {
|
| 188 |
+
const isChecked = checkedItems[idx] || false;
|
| 189 |
+
return (
|
| 190 |
+
<div
|
| 191 |
+
key={idx}
|
| 192 |
+
onClick={() => toggleCheck(idx)}
|
| 193 |
+
className="flex items-start gap-2.5 cursor-pointer group"
|
| 194 |
+
>
|
| 195 |
+
<div className={`mt-0.5 h-4 w-4 rounded border flex items-center justify-center transition shrink-0 ${
|
| 196 |
+
isChecked
|
| 197 |
+
? 'bg-cyan-500 border-cyan-500 text-slate-950'
|
| 198 |
+
: 'border-slate-700 bg-transparent group-hover:border-slate-500'
|
| 199 |
+
}`}>
|
| 200 |
+
{isChecked && (
|
| 201 |
+
<svg className="w-3 h-3 fill-current stroke-[3px]" viewBox="0 0 20 20">
|
| 202 |
+
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z" />
|
| 203 |
+
</svg>
|
| 204 |
+
)}
|
| 205 |
+
</div>
|
| 206 |
+
<span className={`text-xs select-none transition ${isChecked ? 'line-through text-slate-500' : 'text-slate-300'}`}>
|
| 207 |
+
{item}
|
| 208 |
+
</span>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
})}
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
);
|
| 219 |
+
}
|
frontend/components/EmailList.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Mail, Star, Search, Paperclip } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface EmailListItem {
|
| 5 |
+
id: str;
|
| 6 |
+
subject?: str;
|
| 7 |
+
sender: str;
|
| 8 |
+
received_at: str;
|
| 9 |
+
category: str;
|
| 10 |
+
is_read: bool;
|
| 11 |
+
is_starred: bool;
|
| 12 |
+
thread_id?: str;
|
| 13 |
+
has_attachment: bool;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface EmailListProps {
|
| 17 |
+
emails: EmailListItem[];
|
| 18 |
+
selectedId: string | null;
|
| 19 |
+
onSelectEmail: (id: string) => void;
|
| 20 |
+
onToggleStar: (id: string, isStarred: boolean, e: React.MouseEvent) => void;
|
| 21 |
+
activeCategory: string | null;
|
| 22 |
+
setActiveCategory: (category: string | null) => void;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function EmailList({
|
| 26 |
+
emails,
|
| 27 |
+
selectedId,
|
| 28 |
+
onSelectEmail,
|
| 29 |
+
onToggleStar,
|
| 30 |
+
activeCategory,
|
| 31 |
+
setActiveCategory,
|
| 32 |
+
}: EmailListProps) {
|
| 33 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 34 |
+
|
| 35 |
+
// Filter categories
|
| 36 |
+
const categories = [
|
| 37 |
+
{ label: 'All Mail', value: null },
|
| 38 |
+
{ label: 'Urgent', value: 'urgent', color: 'border-red-500/30 text-red-400 bg-red-950/20' },
|
| 39 |
+
{ label: 'Personal', value: 'personal', color: 'border-emerald-500/30 text-emerald-400 bg-emerald-950/20' },
|
| 40 |
+
{ label: 'Invoices', value: 'invoice', color: 'border-cyan-500/30 text-cyan-400 bg-cyan-950/20' },
|
| 41 |
+
{ label: 'Newsletters', value: 'newsletter', color: 'border-slate-500/30 text-slate-400 bg-slate-950/10' },
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
// Apply local text search filter
|
| 45 |
+
const filteredEmails = emails.filter((email) => {
|
| 46 |
+
const matchesSearch =
|
| 47 |
+
(email.subject || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 48 |
+
email.sender.toLowerCase().includes(searchQuery.toLowerCase());
|
| 49 |
+
return matchesSearch;
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
const getCategoryStyle = (cat: string) => {
|
| 53 |
+
switch (cat.toLowerCase()) {
|
| 54 |
+
case 'urgent':
|
| 55 |
+
return 'bg-red-950/30 border border-red-500/25 text-red-400';
|
| 56 |
+
case 'invoice':
|
| 57 |
+
return 'bg-cyan-950/30 border border-cyan-500/25 text-cyan-400';
|
| 58 |
+
case 'newsletter':
|
| 59 |
+
return 'bg-slate-900 border border-slate-700/50 text-slate-400';
|
| 60 |
+
default:
|
| 61 |
+
return 'bg-emerald-950/30 border border-emerald-500/25 text-emerald-400';
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const formatSender = (senderStr: string) => {
|
| 66 |
+
// Return display name or clean email
|
| 67 |
+
const match = senderStr.match(/^([^<]+)/);
|
| 68 |
+
return match ? match[1].trim() : senderStr;
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const formatTime = (isoString: string) => {
|
| 72 |
+
try {
|
| 73 |
+
const date = new Date(isoString);
|
| 74 |
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| 75 |
+
} catch {
|
| 76 |
+
return '';
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div className="flex flex-col h-full bg-slate-950/30 border border-slate-900 rounded-2xl overflow-hidden">
|
| 82 |
+
{/* Search Header */}
|
| 83 |
+
<div className="p-4 border-b border-slate-900 flex gap-3 items-center bg-slate-950/20">
|
| 84 |
+
<div className="relative flex-1">
|
| 85 |
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
| 86 |
+
<input
|
| 87 |
+
type="text"
|
| 88 |
+
placeholder="Search email subject or sender..."
|
| 89 |
+
value={searchQuery}
|
| 90 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 91 |
+
className="w-full pl-10 pr-4 py-2 text-sm rounded-xl glass-input placeholder-slate-500"
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Tabs */}
|
| 97 |
+
<div className="flex px-4 py-3 gap-2 overflow-x-auto border-b border-slate-900/60 bg-slate-950/10">
|
| 98 |
+
{categories.map((cat) => {
|
| 99 |
+
const isActive = activeCategory === cat.value;
|
| 100 |
+
return (
|
| 101 |
+
<button
|
| 102 |
+
key={cat.label}
|
| 103 |
+
onClick={() => setActiveCategory(cat.value)}
|
| 104 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-medium border whitespace-nowrap cursor-pointer transition-all ${
|
| 105 |
+
isActive
|
| 106 |
+
? 'bg-purple-600/20 border-purple-500/60 text-purple-200'
|
| 107 |
+
: 'bg-transparent border-slate-900 text-slate-400 hover:text-slate-200 hover:border-slate-800'
|
| 108 |
+
}`}
|
| 109 |
+
>
|
| 110 |
+
{cat.label}
|
| 111 |
+
</button>
|
| 112 |
+
);
|
| 113 |
+
})}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* Email Scrollable List */}
|
| 117 |
+
<div className="flex-1 overflow-y-auto divide-y divide-slate-900/60">
|
| 118 |
+
{filteredEmails.length === 0 ? (
|
| 119 |
+
<div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-2">
|
| 120 |
+
<Mail size={24} className="stroke-[1.5]" />
|
| 121 |
+
<p className="text-xs">No emails found in this category.</p>
|
| 122 |
+
</div>
|
| 123 |
+
) : (
|
| 124 |
+
filteredEmails.map((email) => {
|
| 125 |
+
const isSelected = selectedId === email.id;
|
| 126 |
+
return (
|
| 127 |
+
<div
|
| 128 |
+
key={email.id}
|
| 129 |
+
onClick={() => onSelectEmail(email.id)}
|
| 130 |
+
className={`p-4 flex gap-3 transition-all relative cursor-pointer group ${
|
| 131 |
+
isSelected
|
| 132 |
+
? 'bg-purple-950/10 border-l-2 border-purple-500'
|
| 133 |
+
: 'hover:bg-slate-900/40 border-l-2 border-transparent'
|
| 134 |
+
}`}
|
| 135 |
+
>
|
| 136 |
+
{/* Unread indicator */}
|
| 137 |
+
{!email.is_read && (
|
| 138 |
+
<span className="absolute top-5 left-1 h-2 w-2 rounded-full bg-purple-500 shadow-[0_0_8px_#a855f7]" />
|
| 139 |
+
)}
|
| 140 |
+
|
| 141 |
+
<div className="flex-1 min-w-0">
|
| 142 |
+
<div className="flex items-center justify-between gap-2 mb-1.5">
|
| 143 |
+
<span className={`font-semibold text-xs ${!email.is_read ? 'text-white' : 'text-slate-300'}`}>
|
| 144 |
+
{formatSender(email.sender)}
|
| 145 |
+
</span>
|
| 146 |
+
<span className="text-[10px] text-slate-500 font-medium">
|
| 147 |
+
{formatTime(email.received_at)}
|
| 148 |
+
</span>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<h4 className={`text-sm mb-1.5 truncate ${!email.is_read ? 'font-semibold text-white' : 'text-slate-300'}`}>
|
| 152 |
+
{email.subject || '(No Subject)'}
|
| 153 |
+
</h4>
|
| 154 |
+
|
| 155 |
+
<div className="flex items-center justify-between mt-2.5">
|
| 156 |
+
{/* Category Label */}
|
| 157 |
+
<span className={`px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider ${getCategoryStyle(email.category)}`}>
|
| 158 |
+
{email.category}
|
| 159 |
+
</span>
|
| 160 |
+
|
| 161 |
+
{/* Star & Attachment indicator */}
|
| 162 |
+
<div className="flex items-center gap-2 text-slate-500 group-hover:text-slate-400">
|
| 163 |
+
{email.has_attachment && <Paperclip size={12} className="stroke-[1.5]" />}
|
| 164 |
+
<button
|
| 165 |
+
onClick={(e) => onToggleStar(email.id, !email.is_starred, e)}
|
| 166 |
+
className="hover:scale-115 transition"
|
| 167 |
+
>
|
| 168 |
+
<Star
|
| 169 |
+
size={14}
|
| 170 |
+
className={email.is_starred ? 'fill-amber-400 stroke-amber-400' : 'stroke-[1.5]'}
|
| 171 |
+
/>
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
);
|
| 178 |
+
})
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
frontend/components/StreamingDraft.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Send, Sparkles, AlertCircle, CheckCircle } from 'lucide-react';
|
| 3 |
+
import { API_BASE_URL } from '@/app/config';
|
| 4 |
+
|
| 5 |
+
interface StreamingDraftProps {
|
| 6 |
+
emailId: string;
|
| 7 |
+
emailSubject?: string;
|
| 8 |
+
emailSender: string;
|
| 9 |
+
onSendEmail: (to: string, subject: string, body: string) => Promise<boolean>;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function StreamingDraft({
|
| 13 |
+
emailId,
|
| 14 |
+
emailSubject,
|
| 15 |
+
emailSender,
|
| 16 |
+
onSendEmail,
|
| 17 |
+
}: StreamingDraftProps) {
|
| 18 |
+
const [tone, setTone] = useState<'professional' | 'casual' | 'short'>('professional');
|
| 19 |
+
const [draftContent, setDraftContent] = useState('');
|
| 20 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 21 |
+
const [isSending, setIsSending] = useState(false);
|
| 22 |
+
const [sendStatus, setSendStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
| 23 |
+
const [errorMsg, setErrorMsg] = useState('');
|
| 24 |
+
|
| 25 |
+
// Clear draft when email selection changes
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
setDraftContent('');
|
| 28 |
+
setSendStatus('idle');
|
| 29 |
+
}, [emailId]);
|
| 30 |
+
|
| 31 |
+
const generateDraft = async () => {
|
| 32 |
+
setIsGenerating(true);
|
| 33 |
+
setDraftContent('');
|
| 34 |
+
setSendStatus('idle');
|
| 35 |
+
|
| 36 |
+
// Connect to FastAPI SSE endpoint
|
| 37 |
+
const url = `${API_BASE_URL}/api/emails/${emailId}/draft?tone=${tone}`;
|
| 38 |
+
|
| 39 |
+
// Access token from localStorage
|
| 40 |
+
const token = localStorage.getItem('token') || '';
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const response = await fetch(url, {
|
| 44 |
+
headers: {
|
| 45 |
+
'Authorization': `Bearer ${token}`,
|
| 46 |
+
},
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
if (!response.ok) {
|
| 50 |
+
throw new Error('Failed to generate draft stream.');
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const reader = response.body?.getReader();
|
| 54 |
+
const decoder = new TextDecoder();
|
| 55 |
+
if (!reader) {
|
| 56 |
+
throw new Error('No readable stream body.');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
let done = false;
|
| 60 |
+
let fullText = '';
|
| 61 |
+
|
| 62 |
+
while (!done) {
|
| 63 |
+
const { value, done: doneReading } = await reader.read();
|
| 64 |
+
done = doneReading;
|
| 65 |
+
if (value) {
|
| 66 |
+
const chunkStr = decoder.decode(value);
|
| 67 |
+
// Parse Server Sent Events format "data: text\n\n"
|
| 68 |
+
const lines = chunkStr.split('\n');
|
| 69 |
+
for (const line of lines) {
|
| 70 |
+
if (line.startsWith('data: ')) {
|
| 71 |
+
const content = line.substring(6);
|
| 72 |
+
fullText += content;
|
| 73 |
+
setDraftContent(fullText);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
} catch (err: any) {
|
| 79 |
+
console.error(err);
|
| 80 |
+
setErrorMsg(err.message || 'Connection lost during drafting.');
|
| 81 |
+
setSendStatus('error');
|
| 82 |
+
} finally {
|
| 83 |
+
setIsGenerating(false);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const handleSend = async () => {
|
| 88 |
+
if (!draftContent.trim()) return;
|
| 89 |
+
|
| 90 |
+
setIsSending(true);
|
| 91 |
+
setSendStatus('idle');
|
| 92 |
+
|
| 93 |
+
// Extract target email address
|
| 94 |
+
let toEmail = emailSender;
|
| 95 |
+
const match = emailSender.match(/<([^>]+)>/);
|
| 96 |
+
if (match) {
|
| 97 |
+
toEmail = match[1];
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const subject = emailSubject ? `Re: ${emailSubject}` : 'Re: Email';
|
| 101 |
+
const success = await onSendEmail(toEmail, subject, draftContent);
|
| 102 |
+
|
| 103 |
+
setIsSending(false);
|
| 104 |
+
if (success) {
|
| 105 |
+
setSendStatus('success');
|
| 106 |
+
} else {
|
| 107 |
+
setErrorMsg('Failed to deliver reply.');
|
| 108 |
+
setSendStatus('error');
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const tones = [
|
| 113 |
+
{ label: '💼 Professional', value: 'professional' },
|
| 114 |
+
{ label: '☕ Casual', value: 'casual' },
|
| 115 |
+
{ label: '⚡ Short / Quick', value: 'short' },
|
| 116 |
+
];
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<div className="glass-panel border-purple-500/10 rounded-2xl p-6 flex flex-col gap-4">
|
| 120 |
+
<div className="flex items-center justify-between">
|
| 121 |
+
<div className="flex items-center gap-2 text-purple-400 font-semibold text-sm">
|
| 122 |
+
<Sparkles size={16} className="fill-purple-400/20" />
|
| 123 |
+
<span>AI Streaming Reply Composer</span>
|
| 124 |
+
</div>
|
| 125 |
+
<div className="text-xs text-slate-500">
|
| 126 |
+
Powered by GPT-4o-Mini
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* Tone Selection */}
|
| 131 |
+
<div className="flex gap-2.5 bg-slate-950/40 border border-slate-900/60 p-1 rounded-xl">
|
| 132 |
+
{tones.map((t) => (
|
| 133 |
+
<button
|
| 134 |
+
key={t.value}
|
| 135 |
+
disabled={isGenerating || isSending}
|
| 136 |
+
onClick={() => setTone(t.value as any)}
|
| 137 |
+
className={`flex-1 text-center py-2 text-xs font-medium rounded-lg cursor-pointer transition ${
|
| 138 |
+
tone === t.value
|
| 139 |
+
? 'bg-purple-600/20 text-purple-300 border border-purple-500/20'
|
| 140 |
+
: 'text-slate-500 hover:text-slate-300 border border-transparent'
|
| 141 |
+
}`}
|
| 142 |
+
>
|
| 143 |
+
{t.label}
|
| 144 |
+
</button>
|
| 145 |
+
))}
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
{/* Generator trigger button */}
|
| 149 |
+
{draftContent === '' && !isGenerating && (
|
| 150 |
+
<button
|
| 151 |
+
onClick={generateDraft}
|
| 152 |
+
className="glow-btn text-white py-3 rounded-xl font-medium flex items-center justify-center gap-2 text-sm cursor-pointer"
|
| 153 |
+
>
|
| 154 |
+
<Sparkles size={14} />
|
| 155 |
+
<span>Draft Response in {tone.toUpperCase()} Tone</span>
|
| 156 |
+
</button>
|
| 157 |
+
)}
|
| 158 |
+
|
| 159 |
+
{/* Draft text editor */}
|
| 160 |
+
{(draftContent !== '' || isGenerating) && (
|
| 161 |
+
<div className="flex flex-col gap-3">
|
| 162 |
+
<textarea
|
| 163 |
+
value={draftContent}
|
| 164 |
+
onChange={(e) => setDraftContent(e.target.value)}
|
| 165 |
+
disabled={isGenerating || isSending}
|
| 166 |
+
rows={8}
|
| 167 |
+
className="w-full text-sm font-sans leading-relaxed bg-slate-950/50 p-4 border border-slate-900 rounded-xl focus:outline-none focus:border-purple-600 resize-y text-slate-300"
|
| 168 |
+
placeholder={isGenerating ? "AI is typing reply word-by-word..." : "Edit response..."}
|
| 169 |
+
/>
|
| 170 |
+
|
| 171 |
+
<div className="flex items-center justify-between mt-1">
|
| 172 |
+
{/* Status indicators */}
|
| 173 |
+
<div>
|
| 174 |
+
{isGenerating && (
|
| 175 |
+
<div className="flex items-center gap-2 text-purple-400 text-xs font-medium">
|
| 176 |
+
<div className="h-3 w-3 animate-spin rounded-full border border-purple-400 border-t-transparent" />
|
| 177 |
+
<span>Streaming draft...</span>
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
{sendStatus === 'success' && (
|
| 181 |
+
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-medium">
|
| 182 |
+
<CheckCircle size={14} />
|
| 183 |
+
<span>Reply sent successfully!</span>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
{sendStatus === 'error' && (
|
| 187 |
+
<div className="flex items-center gap-1.5 text-red-400 text-xs font-medium">
|
| 188 |
+
<AlertCircle size={14} />
|
| 189 |
+
<span>{errorMsg}</span>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div className="flex gap-2">
|
| 195 |
+
<button
|
| 196 |
+
onClick={generateDraft}
|
| 197 |
+
disabled={isGenerating || isSending}
|
| 198 |
+
className="bg-slate-950 border border-slate-800 text-slate-300 hover:text-white px-4 py-2 rounded-xl text-xs font-semibold cursor-pointer transition disabled:opacity-50"
|
| 199 |
+
>
|
| 200 |
+
Regenerate
|
| 201 |
+
</button>
|
| 202 |
+
|
| 203 |
+
<button
|
| 204 |
+
onClick={handleSend}
|
| 205 |
+
disabled={isGenerating || isSending || !draftContent.trim()}
|
| 206 |
+
className="glow-btn text-white px-4.5 py-2 rounded-xl text-xs font-semibold flex items-center gap-1.5 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
| 207 |
+
>
|
| 208 |
+
<Send size={12} />
|
| 209 |
+
<span>{isSending ? 'Sending...' : 'Send Reply'}</span>
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
</div>
|
| 216 |
+
);
|
| 217 |
+
}
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"lucide-react": "^1.21.0",
|
| 13 |
+
"next": "16.2.9",
|
| 14 |
+
"react": "19.2.4",
|
| 15 |
+
"react-dom": "19.2.4"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@tailwindcss/postcss": "^4",
|
| 19 |
+
"@types/node": "^20",
|
| 20 |
+
"@types/react": "^19",
|
| 21 |
+
"@types/react-dom": "^19",
|
| 22 |
+
"eslint": "^9",
|
| 23 |
+
"eslint-config-next": "16.2.9",
|
| 24 |
+
"tailwindcss": "^4",
|
| 25 |
+
"typescript": "^5"
|
| 26 |
+
}
|
| 27 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|
grafana_dashboard.json
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"annotations": {
|
| 3 |
+
"list": [
|
| 4 |
+
{
|
| 5 |
+
"builtIn": 1,
|
| 6 |
+
"datasource": {
|
| 7 |
+
"type": "datasource",
|
| 8 |
+
"uid": "grafana"
|
| 9 |
+
},
|
| 10 |
+
"enable": true,
|
| 11 |
+
"hide": true,
|
| 12 |
+
"name": "Annotations & Alerts",
|
| 13 |
+
"type": "dashboard"
|
| 14 |
+
}
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
"editable": true,
|
| 18 |
+
"fiscalYearStartMonth": 0,
|
| 19 |
+
"graphTooltip": 0,
|
| 20 |
+
"id": null,
|
| 21 |
+
"links": [],
|
| 22 |
+
"liveNow": false,
|
| 23 |
+
"panels": [
|
| 24 |
+
{
|
| 25 |
+
"collapsed": false,
|
| 26 |
+
"gridPos": {
|
| 27 |
+
"h": 8,
|
| 28 |
+
"w": 12,
|
| 29 |
+
"x": 0,
|
| 30 |
+
"y": 0
|
| 31 |
+
},
|
| 32 |
+
"id": 1,
|
| 33 |
+
"title": "API Request Rate (QPS)",
|
| 34 |
+
"type": "timeseries",
|
| 35 |
+
"datasource": {
|
| 36 |
+
"type": "prometheus",
|
| 37 |
+
"uid": "prometheus"
|
| 38 |
+
},
|
| 39 |
+
"targets": [
|
| 40 |
+
{
|
| 41 |
+
"datasource": {
|
| 42 |
+
"type": "prometheus",
|
| 43 |
+
"uid": "prometheus"
|
| 44 |
+
},
|
| 45 |
+
"editorMode": "code",
|
| 46 |
+
"expr": "sum(rate(http_requests_total[1m]))",
|
| 47 |
+
"legendFormat": "Requests per Second",
|
| 48 |
+
"range": true,
|
| 49 |
+
"refId": "A"
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"fieldConfig": {
|
| 53 |
+
"defaults": {
|
| 54 |
+
"custom": {
|
| 55 |
+
"drawStyle": "line",
|
| 56 |
+
"lineInterpolation": "smooth",
|
| 57 |
+
"fillOpacity": 15
|
| 58 |
+
},
|
| 59 |
+
"unit": "req/sec"
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"collapsed": false,
|
| 65 |
+
"gridPos": {
|
| 66 |
+
"h": 8,
|
| 67 |
+
"w": 12,
|
| 68 |
+
"x": 12,
|
| 69 |
+
"y": 0
|
| 70 |
+
},
|
| 71 |
+
"id": 2,
|
| 72 |
+
"title": "Average Request Latency",
|
| 73 |
+
"type": "timeseries",
|
| 74 |
+
"datasource": {
|
| 75 |
+
"type": "prometheus",
|
| 76 |
+
"uid": "prometheus"
|
| 77 |
+
},
|
| 78 |
+
"targets": [
|
| 79 |
+
{
|
| 80 |
+
"datasource": {
|
| 81 |
+
"type": "prometheus",
|
| 82 |
+
"uid": "prometheus"
|
| 83 |
+
},
|
| 84 |
+
"editorMode": "code",
|
| 85 |
+
"expr": "sum(rate(http_request_duration_seconds_sum[5m])) / sum(rate(http_request_duration_seconds_count[5m]))",
|
| 86 |
+
"legendFormat": "Latency (s)",
|
| 87 |
+
"range": true,
|
| 88 |
+
"refId": "A"
|
| 89 |
+
}
|
| 90 |
+
],
|
| 91 |
+
"fieldConfig": {
|
| 92 |
+
"defaults": {
|
| 93 |
+
"custom": {
|
| 94 |
+
"drawStyle": "line",
|
| 95 |
+
"lineInterpolation": "smooth"
|
| 96 |
+
},
|
| 97 |
+
"unit": "s"
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"collapsed": false,
|
| 103 |
+
"gridPos": {
|
| 104 |
+
"h": 8,
|
| 105 |
+
"w": 12,
|
| 106 |
+
"x": 0,
|
| 107 |
+
"y": 8
|
| 108 |
+
},
|
| 109 |
+
"id": 3,
|
| 110 |
+
"title": "Celery Task Execution Rate",
|
| 111 |
+
"type": "timeseries",
|
| 112 |
+
"datasource": {
|
| 113 |
+
"type": "prometheus",
|
| 114 |
+
"uid": "prometheus"
|
| 115 |
+
},
|
| 116 |
+
"targets": [
|
| 117 |
+
{
|
| 118 |
+
"datasource": {
|
| 119 |
+
"type": "prometheus",
|
| 120 |
+
"uid": "prometheus"
|
| 121 |
+
},
|
| 122 |
+
"editorMode": "code",
|
| 123 |
+
"expr": "sum(rate(celery_task_status_count_total[5m])) by (status)",
|
| 124 |
+
"legendFormat": "{{status}} Tasks",
|
| 125 |
+
"range": true,
|
| 126 |
+
"refId": "A"
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
"fieldConfig": {
|
| 130 |
+
"defaults": {
|
| 131 |
+
"custom": {
|
| 132 |
+
"drawStyle": "line"
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"collapsed": false,
|
| 139 |
+
"gridPos": {
|
| 140 |
+
"h": 8,
|
| 141 |
+
"w": 12,
|
| 142 |
+
"x": 12,
|
| 143 |
+
"y": 8
|
| 144 |
+
},
|
| 145 |
+
"id": 4,
|
| 146 |
+
"title": "AI Agent Runs (Success / Failure)",
|
| 147 |
+
"type": "timeseries",
|
| 148 |
+
"datasource": {
|
| 149 |
+
"type": "prometheus",
|
| 150 |
+
"uid": "prometheus"
|
| 151 |
+
},
|
| 152 |
+
"targets": [
|
| 153 |
+
{
|
| 154 |
+
"datasource": {
|
| 155 |
+
"type": "prometheus",
|
| 156 |
+
"uid": "prometheus"
|
| 157 |
+
},
|
| 158 |
+
"editorMode": "code",
|
| 159 |
+
"expr": "sum(rate(agent_run_duration_seconds_count[5m])) by (status)",
|
| 160 |
+
"legendFormat": "Agent Status: {{status}}",
|
| 161 |
+
"range": true,
|
| 162 |
+
"refId": "A"
|
| 163 |
+
}
|
| 164 |
+
],
|
| 165 |
+
"fieldConfig": {
|
| 166 |
+
"defaults": {
|
| 167 |
+
"custom": {
|
| 168 |
+
"drawStyle": "line"
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
],
|
| 174 |
+
"schemaVersion": 38,
|
| 175 |
+
"style": "dark",
|
| 176 |
+
"tags": ["fastapi", "email-agent"],
|
| 177 |
+
"time": {
|
| 178 |
+
"from": "now-1h",
|
| 179 |
+
"to": "now"
|
| 180 |
+
},
|
| 181 |
+
"timepicker": {},
|
| 182 |
+
"timezone": "browser",
|
| 183 |
+
"title": "Agentic AI Email Observability Dashboard",
|
| 184 |
+
"version": 1
|
| 185 |
+
}
|
prometheus.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
global:
|
| 2 |
+
scrape_interval: 5s
|
| 3 |
+
evaluation_interval: 5s
|
| 4 |
+
|
| 5 |
+
scrape_configs:
|
| 6 |
+
- job_name: "fastapi-backend"
|
| 7 |
+
metrics_path: "/metrics"
|
| 8 |
+
static_configs:
|
| 9 |
+
- targets: ["host.docker.internal:8000"]
|