Spaces:
Runtime error
Runtime error
Deploy BrowserPilot with NumPy fix (2.2.6)
Browse files- .dockerignore +27 -0
- .env.test +1 -0
- .github/workflows/README.md +58 -0
- .github/workflows/docker-build.yml +51 -0
- .github/workflows/keepalive.yml +50 -0
- .gitignore +31 -0
- DEPLOY_HUGGINGFACE.md +105 -0
- DEPLOY_RENDER.md +177 -0
- Dockerfile +80 -0
- KEEPALIVE.md +44 -0
- README.docker.md +201 -0
- README.md +233 -10
- README_HF.md +30 -0
- SETUP_COMPLETE.md +118 -0
- TELEGRAM_BOT.md +162 -0
- docker-compose.prod.yml +30 -0
- docker-compose.yml +35 -0
- entrypoint.sh +30 -0
- frontend/.gitignore +25 -0
- frontend/README.md +1 -0
- frontend/eslint.config.js +28 -0
- frontend/index.html +17 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +33 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +8 -0
- frontend/src/components/BrowserPilotDashboard.tsx +189 -0
- frontend/src/components/DecisionLog.tsx +78 -0
- frontend/src/components/Header.tsx +96 -0
- frontend/src/components/JobForm.tsx +297 -0
- frontend/src/components/ProxyStats.tsx +67 -0
- frontend/src/components/ScreenshotGallery.tsx +105 -0
- frontend/src/components/StatusDisplay.tsx +93 -0
- frontend/src/components/StreamingViewer.tsx +232 -0
- frontend/src/components/TokenUsage.tsx +67 -0
- frontend/src/index.css +386 -0
- frontend/src/main.tsx +10 -0
- frontend/src/services/WebSocketManager.ts +178 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tailwind.config.js +72 -0
- frontend/tsconfig.app.json +24 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +22 -0
- frontend/vite.config.ts +10 -0
- render.yaml +24 -0
- requirements.txt +18 -0
.dockerignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
*.md
|
| 4 |
+
!README.md
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
__pycache__
|
| 8 |
+
*.pyc
|
| 9 |
+
.pytest_cache
|
| 10 |
+
.coverage
|
| 11 |
+
*.egg-info
|
| 12 |
+
dist
|
| 13 |
+
build
|
| 14 |
+
node_modules
|
| 15 |
+
frontend/node_modules
|
| 16 |
+
frontend/dist
|
| 17 |
+
*.log
|
| 18 |
+
.DS_Store
|
| 19 |
+
Thumbs.db
|
| 20 |
+
.vscode
|
| 21 |
+
.idea
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
| 24 |
+
*~
|
| 25 |
+
.cache
|
| 26 |
+
outputs/*
|
| 27 |
+
!outputs/.gitkeep
|
.env.test
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GOOGLE_API_KEY=dummy_key_for_test
|
.github/workflows/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Actions Workflows
|
| 2 |
+
|
| 3 |
+
## Docker Build (`docker-build.yml`)
|
| 4 |
+
|
| 5 |
+
This workflow automatically builds and publishes the BrowserPilot Docker image when changes are merged to the main branch.
|
| 6 |
+
|
| 7 |
+
### Triggers
|
| 8 |
+
- **Push to main branch**: When code is merged to main, specifically when these files change:
|
| 9 |
+
- `Dockerfile`
|
| 10 |
+
- `docker-compose*.yml`
|
| 11 |
+
- `backend/**`
|
| 12 |
+
- `frontend/**`
|
| 13 |
+
- `requirements.txt`
|
| 14 |
+
- **Pull Requests**: Builds (but doesn't push) images for PRs to test the build process
|
| 15 |
+
- **Manual trigger**: Can be run manually from the GitHub Actions tab
|
| 16 |
+
|
| 17 |
+
### What it does
|
| 18 |
+
1. **Builds the Docker image** using the multi-stage Dockerfile
|
| 19 |
+
2. **Publishes to GitHub Container Registry** (`ghcr.io`) with multiple tags:
|
| 20 |
+
- `latest` (for main branch)
|
| 21 |
+
- `main` (branch name)
|
| 22 |
+
- `main-<sha>` (commit SHA)
|
| 23 |
+
3. **Multi-architecture support**: Builds for both `linux/amd64` and `linux/arm64`
|
| 24 |
+
4. **Caching**: Uses GitHub Actions cache for faster builds
|
| 25 |
+
5. **Security**: Generates build attestations for supply chain security
|
| 26 |
+
|
| 27 |
+
### Published Images
|
| 28 |
+
The Docker images are published to:
|
| 29 |
+
- `ghcr.io/veverkap/browserpilot:latest`
|
| 30 |
+
- `ghcr.io/veverkap/browserpilot:main`
|
| 31 |
+
- `ghcr.io/veverkap/browserpilot:main-<commit-sha>`
|
| 32 |
+
|
| 33 |
+
### Usage
|
| 34 |
+
Users can pull and run the published images:
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
# Pull the latest image
|
| 38 |
+
docker pull ghcr.io/veverkap/browserpilot:latest
|
| 39 |
+
|
| 40 |
+
# Run with Docker
|
| 41 |
+
docker run -p 8000:8000 -e GOOGLE_API_KEY=your_key ghcr.io/veverkap/browserpilot:latest
|
| 42 |
+
|
| 43 |
+
# Or use in docker-compose.yml
|
| 44 |
+
services:
|
| 45 |
+
browserpilot:
|
| 46 |
+
image: ghcr.io/veverkap/browserpilot:latest
|
| 47 |
+
# ... other config
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### Permissions Required
|
| 51 |
+
The workflow needs the following permissions (automatically granted):
|
| 52 |
+
- `contents: read` - To checkout the repository
|
| 53 |
+
- `packages: write` - To publish to GitHub Container Registry
|
| 54 |
+
|
| 55 |
+
### Optional: Docker Hub Integration
|
| 56 |
+
The workflow includes an optional step to update Docker Hub descriptions if you want to also publish to Docker Hub. To enable this:
|
| 57 |
+
1. Add `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets to your repository
|
| 58 |
+
2. The workflow will automatically update the Docker Hub repository description using `README.docker.md`
|
.github/workflows/docker-build.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build and Publish Docker Image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
env:
|
| 10 |
+
REGISTRY: ghcr.io
|
| 11 |
+
IMAGE_NAME: ${{ github.repository }}
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
build-and-publish:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
permissions:
|
| 17 |
+
contents: read
|
| 18 |
+
packages: write
|
| 19 |
+
|
| 20 |
+
steps:
|
| 21 |
+
- name: Checkout repository
|
| 22 |
+
uses: actions/checkout@v4
|
| 23 |
+
|
| 24 |
+
- name: Set up Docker Buildx
|
| 25 |
+
uses: docker/setup-buildx-action@v3
|
| 26 |
+
|
| 27 |
+
- name: Log in to the Container Registry
|
| 28 |
+
uses: docker/login-action@v3
|
| 29 |
+
with:
|
| 30 |
+
registry: ${{ env.REGISTRY }}
|
| 31 |
+
username: ${{ github.actor }}
|
| 32 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 33 |
+
|
| 34 |
+
- name: Extract metadata (tags, labels) for Docker
|
| 35 |
+
id: meta
|
| 36 |
+
uses: docker/metadata-action@v5
|
| 37 |
+
with:
|
| 38 |
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
| 39 |
+
tags: |
|
| 40 |
+
type=ref,event=branch
|
| 41 |
+
type=sha,prefix={{branch}}-
|
| 42 |
+
type=raw,value=latest,enable={{is_default_branch}}
|
| 43 |
+
|
| 44 |
+
- name: Build and push Docker image
|
| 45 |
+
uses: docker/build-push-action@v5
|
| 46 |
+
with:
|
| 47 |
+
context: .
|
| 48 |
+
platforms: linux/amd64,linux/arm64
|
| 49 |
+
push: true
|
| 50 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 51 |
+
labels: ${{ steps.meta.outputs.labels }}
|
.github/workflows/keepalive.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Keep HF Space Awake
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
schedule:
|
| 5 |
+
# Runs every 13 minutes
|
| 6 |
+
- cron: '*/13 * * * *'
|
| 7 |
+
workflow_dispatch: # Allow manual trigger
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
keepalive:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Ping Hugging Face Space
|
| 14 |
+
id: ping
|
| 15 |
+
run: |
|
| 16 |
+
echo "🔍 Pinging Hugging Face Space..."
|
| 17 |
+
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "${{ secrets.HF_SPACE_URL }}")
|
| 18 |
+
echo "✅ Response status: $RESPONSE"
|
| 19 |
+
echo "status_code=$RESPONSE" >> $GITHUB_OUTPUT
|
| 20 |
+
|
| 21 |
+
if [ "$RESPONSE" -ge 200 ] && [ "$RESPONSE" -lt 400 ]; then
|
| 22 |
+
echo "✅ Space is awake!"
|
| 23 |
+
echo "success=true" >> $GITHUB_OUTPUT
|
| 24 |
+
exit 0
|
| 25 |
+
else
|
| 26 |
+
echo "❌ Space returned status: $RESPONSE"
|
| 27 |
+
echo "success=false" >> $GITHUB_OUTPUT
|
| 28 |
+
exit 1
|
| 29 |
+
fi
|
| 30 |
+
env:
|
| 31 |
+
HF_SPACE_URL: ${{ secrets.HF_SPACE_URL }}
|
| 32 |
+
|
| 33 |
+
- name: Notify Telegram on failure
|
| 34 |
+
if: failure()
|
| 35 |
+
run: |
|
| 36 |
+
echo "📧 Sending Telegram alert..."
|
| 37 |
+
curl -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
| 38 |
+
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
| 39 |
+
-d "text=⚠️ <b>KeepAlive Alert</b>%0A%0A🔴 HF Space health check failed!%0A<b>Status:</b> ${{ steps.ping.outputs.status_code }}%0A%0AThe Space might be sleeping or down." \
|
| 40 |
+
-d "parse_mode=HTML"
|
| 41 |
+
|
| 42 |
+
- name: Notify Telegram on success (after previous failure)
|
| 43 |
+
if: success()
|
| 44 |
+
run: |
|
| 45 |
+
echo "📧 Sending Telegram success notification..."
|
| 46 |
+
curl -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
| 47 |
+
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
| 48 |
+
-d "text=✅ <b>KeepAlive OK</b>%0A%0A🟢 HF Space health check passed!%0A<b>Status:</b> ${{ steps.ping.outputs.status_code }}%0A%0AThe Space is awake and running." \
|
| 49 |
+
-d "parse_mode=HTML"
|
| 50 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv
|
| 2 |
+
.env
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
outputs
|
| 6 |
+
|
| 7 |
+
# Logs
|
| 8 |
+
logs
|
| 9 |
+
*.log
|
| 10 |
+
npm-debug.log*
|
| 11 |
+
yarn-debug.log*
|
| 12 |
+
yarn-error.log*
|
| 13 |
+
pnpm-debug.log*
|
| 14 |
+
lerna-debug.log*
|
| 15 |
+
|
| 16 |
+
node_modules
|
| 17 |
+
dist
|
| 18 |
+
dist-ssr
|
| 19 |
+
*.local
|
| 20 |
+
|
| 21 |
+
# Editor directories and files
|
| 22 |
+
.vscode/*
|
| 23 |
+
!.vscode/extensions.json
|
| 24 |
+
.idea
|
| 25 |
+
.DS_Store
|
| 26 |
+
*.suo
|
| 27 |
+
*.ntvs*
|
| 28 |
+
*.njsproj
|
| 29 |
+
*.sln
|
| 30 |
+
*.sw?
|
| 31 |
+
.env
|
DEPLOY_HUGGINGFACE.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Deployment
|
| 2 |
+
|
| 3 |
+
## Deploy en Hugging Face Spaces (100% GRATIS)
|
| 4 |
+
|
| 5 |
+
### Paso 1: Crear cuenta en Hugging Face
|
| 6 |
+
|
| 7 |
+
1. Ir a https://huggingface.co
|
| 8 |
+
2. Click en "Sign Up" (usá GitHub para más facilidad)
|
| 9 |
+
|
| 10 |
+
### Paso 2: Crear un nuevo Space
|
| 11 |
+
|
| 12 |
+
1. Click en tu avatar → **"New Space"**
|
| 13 |
+
2. Configurar el Space:
|
| 14 |
+
- **Space name**: `browserpilot` (o el nombre que quieras)
|
| 15 |
+
- **License**: MIT
|
| 16 |
+
- **SDK**: **Docker**
|
| 17 |
+
- **Visibility**: Public (o Private si querés)
|
| 18 |
+
3. Click en **"Create Space"**
|
| 19 |
+
|
| 20 |
+
### Paso 3: Conectar el repositorio de GitHub
|
| 21 |
+
|
| 22 |
+
1. En el dashboard del Space, vas a ver **"Files"**
|
| 23 |
+
2. Click en **"Import from GitHub"** (o **"Add file"** → **"Import from GitHub"**)
|
| 24 |
+
3. Autorizá Hugging Face a acceder a tu GitHub
|
| 25 |
+
4. Seleccioná el repositorio: `ncolex/33BrowserPilot`
|
| 26 |
+
5. Click en **"Import"**
|
| 27 |
+
|
| 28 |
+
### Paso 4: Configurar las Variables de Entorno
|
| 29 |
+
|
| 30 |
+
1. En el Space, andá a **"Settings"** (pestaña de arriba)
|
| 31 |
+
2. Bajá hasta **"Variables and secrets"**
|
| 32 |
+
3. Agregá las siguientes variables:
|
| 33 |
+
|
| 34 |
+
| Type | Key | Value |
|
| 35 |
+
|------|-----|-------|
|
| 36 |
+
| Secret | `GOOGLE_API_KEY` | `AIzaSyBDl98MXre7ecN9jW16EVltBlaf38awkdo` |
|
| 37 |
+
| Secret | `DATABASE_URL` | `postgresql://neondb_owner:npg_obFVM76KLule@ep-twilight-field-aiwca4xu.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require` |
|
| 38 |
+
| Variable | `PYTHONUNBUFFERED` | `1` |
|
| 39 |
+
|
| 40 |
+
**Para agregar cada una:**
|
| 41 |
+
- Click en **"New secret"** o **"New variable"**
|
| 42 |
+
- Completá el nombre y valor
|
| 43 |
+
- Click en **"Save"**
|
| 44 |
+
|
| 45 |
+
### Paso 5: Esperar el deploy
|
| 46 |
+
|
| 47 |
+
1. Hugging Face va a empezar a construir el Docker automáticamente
|
| 48 |
+
2. El estado va a cambiar: `Building` → `Running`
|
| 49 |
+
3. Puede tardar 5-10 minutos la primera vez
|
| 50 |
+
|
| 51 |
+
### Paso 6: ¡Listo!
|
| 52 |
+
|
| 53 |
+
Una vez que diga **"Running"**, tu BrowserPilot está disponible en:
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
https://huggingface.co/spaces/tu-usuario/browserpilot
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Notas Importantes
|
| 60 |
+
|
| 61 |
+
⚠️ **Recursos limitados**: Los Spaces free tienen 2 vCPU y 16GB RAM, pero son compartidos.
|
| 62 |
+
|
| 63 |
+
⚠️ **Se duerme**: Después de ~48 horas de inactividad, el Space se duerme. El primer request puede tardar 1-2 minutos.
|
| 64 |
+
|
| 65 |
+
⚠️ **Sin GPU**: El plan free no incluye GPU, solo CPU.
|
| 66 |
+
|
| 67 |
+
⚠️ **Storage temporal**: Los archivos en `/app/outputs` se pierden cuando el Space se reinicia. Para storage persistente, necesitás usar Hugging Face Datasets o un servicio externo.
|
| 68 |
+
|
| 69 |
+
## Actualizar el Space
|
| 70 |
+
|
| 71 |
+
Cada vez que hagas push a GitHub:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
cd /home/ncx/BROPILOT/33BrowserPilot
|
| 75 |
+
git push origin main
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Hugging Face va a detectar los cambios y redeployar automáticamente.
|
| 79 |
+
|
| 80 |
+
## Troubleshooting
|
| 81 |
+
|
| 82 |
+
### El Space no arranca
|
| 83 |
+
- Revisá los logs en la pestaña **"Logs"**
|
| 84 |
+
- Verificá que la `GOOGLE_API_KEY` esté configurada
|
| 85 |
+
- Checkeá que el Dockerfile sea correcto
|
| 86 |
+
|
| 87 |
+
### Error de memoria
|
| 88 |
+
- El Space free tiene límites de memoria
|
| 89 |
+
- Intentá reducir el uso de memoria en el código
|
| 90 |
+
- Considerá upgrade al plan Pro ($9/mes) si necesitás más recursos
|
| 91 |
+
|
| 92 |
+
### Los archivos no persisten
|
| 93 |
+
- Los Spaces no tienen storage persistente en el plan free
|
| 94 |
+
- Para guardar archivos permanentemente, usá:
|
| 95 |
+
- Hugging Face Datasets
|
| 96 |
+
- Un servicio externo (S3, Google Drive, etc.)
|
| 97 |
+
- La base de datos Neon para metadata
|
| 98 |
+
|
| 99 |
+
## Opcional: Agregar storage persistente
|
| 100 |
+
|
| 101 |
+
Para guardar los outputs permanentemente, podés usar Hugging Face Datasets:
|
| 102 |
+
|
| 103 |
+
1. Crear un Dataset en Hugging Face
|
| 104 |
+
2. Montarlo en el Space desde Settings → Datasets
|
| 105 |
+
3. Guardar los archivos en `/datasets/tu-usuario/tu-dataset`
|
DEPLOY_RENDER.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Despliegue en Render.com + Neon Database
|
| 2 |
+
|
| 3 |
+
## Arquitectura
|
| 4 |
+
|
| 5 |
+
- **Aplicación**: Render.com (Docker)
|
| 6 |
+
- **Base de Datos**: Neon PostgreSQL (gratis, 500MB)
|
| 7 |
+
- **Outputs**: Disco persistente en Render (5GB)
|
| 8 |
+
|
| 9 |
+
## Paso 1: Crear la Base de Datos en Neon
|
| 10 |
+
|
| 11 |
+
1. **Ir a Neon**
|
| 12 |
+
- Visitá https://neon.tech
|
| 13 |
+
- Click en "Sign Up" (usá GitHub para más facilidad)
|
| 14 |
+
|
| 15 |
+
2. **Crear un nuevo proyecto**
|
| 16 |
+
- Click en "New Project"
|
| 17 |
+
- **Name**: `browserpilot-db`
|
| 18 |
+
- **Region**: Us-West (Oregon) - misma que Render
|
| 19 |
+
- Click en "Create Project"
|
| 20 |
+
|
| 21 |
+
3. **Obtener la DATABASE_URL**
|
| 22 |
+
- En el dashboard del proyecto, vas a ver la "Connection String"
|
| 23 |
+
- Copiá el string que dice algo como:
|
| 24 |
+
```
|
| 25 |
+
postgresql://user:password@ep-xxx.us-west-2.aws.neon.tech/dbname?sslmode=require
|
| 26 |
+
```
|
| 27 |
+
- Guardala para el próximo paso
|
| 28 |
+
|
| 29 |
+
4. **Configuración opcional**
|
| 30 |
+
- Podés ver la base de datos desde el dashboard de Neon
|
| 31 |
+
- Las tablas se crean automáticamente al iniciar la app
|
| 32 |
+
|
| 33 |
+
## Paso 2: Desplegar en Render
|
| 34 |
+
|
| 35 |
+
### Opción 1: Usando el Dashboard de Render (Recomendado)
|
| 36 |
+
|
| 37 |
+
1. **Crear una cuenta en Render**
|
| 38 |
+
- Ir a https://render.com y registrarse
|
| 39 |
+
|
| 40 |
+
2. **Crear un nuevo Web Service**
|
| 41 |
+
- Click en "New +" → "Web Service"
|
| 42 |
+
- Conectar tu repositorio de GitHub
|
| 43 |
+
|
| 44 |
+
3. **Configurar el servicio**
|
| 45 |
+
- **Name**: `browserpilot` (o el nombre que quieras)
|
| 46 |
+
- **Region**: Oregon (us-west-2) - misma que Neon
|
| 47 |
+
- **Branch**: `main`
|
| 48 |
+
- **Root Directory**: (dejar vacío)
|
| 49 |
+
- **Runtime**: `Docker`
|
| 50 |
+
- **Docker Command**: (dejar vacío)
|
| 51 |
+
|
| 52 |
+
4. **Configurar Variables de Entorno**
|
| 53 |
+
En la sección "Environment", agregar:
|
| 54 |
+
|
| 55 |
+
| Key | Value |
|
| 56 |
+
|-----|-------|
|
| 57 |
+
| `GOOGLE_API_KEY` | `AIzaSyBDl98MXre7ecN9jW16EVltBlaf38awkdo` |
|
| 58 |
+
| `DATABASE_URL` | (pegá el connection string de Neon) |
|
| 59 |
+
| `SCRAPER_PROXIES` | `[]` (o tu configuración de proxies si tenés) |
|
| 60 |
+
|
| 61 |
+
5. **Configurar Disk (opcional pero recomendado)**
|
| 62 |
+
- Click en "Add Disk"
|
| 63 |
+
- **Name**: `outputs`
|
| 64 |
+
- **Mount Path**: `/app/outputs`
|
| 65 |
+
- **Size**: 5 GB (mínimo)
|
| 66 |
+
|
| 67 |
+
6. **Seleccionar Plan**
|
| 68 |
+
- **Starter** ($7/mes) - recomendado para empezar
|
| 69 |
+
- El plan free no soporta Docker
|
| 70 |
+
|
| 71 |
+
7. **Deploy**
|
| 72 |
+
- Click en "Create Web Service"
|
| 73 |
+
- Esperar a que se complete el despliegue (~5-10 minutos)
|
| 74 |
+
|
| 75 |
+
### Opción 2: Usando Render CLI
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
# Instalar Render CLI
|
| 79 |
+
npm install -g @render-cloud/renderctl
|
| 80 |
+
|
| 81 |
+
# Login
|
| 82 |
+
renderctl login
|
| 83 |
+
|
| 84 |
+
# Deploy usando el render.yaml
|
| 85 |
+
cd /home/ncx/BROPILOT/33BrowserPilot
|
| 86 |
+
renderctl up -f render.yaml
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## Configuración del render.yaml
|
| 90 |
+
|
| 91 |
+
El archivo `render.yaml` ya está configurado con:
|
| 92 |
+
- Tipo: Web Service con Docker
|
| 93 |
+
- Región: Oregon
|
| 94 |
+
- Plan: Starter
|
| 95 |
+
- Health check: `/`
|
| 96 |
+
- Disco persistente para outputs
|
| 97 |
+
|
| 98 |
+
## Después del despliegue
|
| 99 |
+
|
| 100 |
+
1. **Verificar el deploy**
|
| 101 |
+
- Ir al dashboard de Render
|
| 102 |
+
- Ver los logs en tiempo real
|
| 103 |
+
- Verificar que el health check pase
|
| 104 |
+
|
| 105 |
+
2. **Acceder a la aplicación**
|
| 106 |
+
- La URL será algo como: `https://browserpilot-xxxx.onrender.com`
|
| 107 |
+
|
| 108 |
+
3. **Configurar dominio personalizado (opcional)**
|
| 109 |
+
- En el dashboard → Settings → Custom Domain
|
| 110 |
+
|
| 111 |
+
## Variables de Entorno Requeridas
|
| 112 |
+
|
| 113 |
+
| Variable | Requerida | Descripción |
|
| 114 |
+
|----------|-----------|-------------|
|
| 115 |
+
| `GOOGLE_API_KEY` | ✅ Sí | API key de Google Gemini |
|
| 116 |
+
| `DATABASE_URL` | ✅ Sí | Connection string de Neon PostgreSQL |
|
| 117 |
+
| `SCRAPER_PROXIES` | ❌ No | Configuración de proxies (JSON array) |
|
| 118 |
+
|
| 119 |
+
## Costos Estimados
|
| 120 |
+
|
| 121 |
+
| Servicio | Plan | Costo |
|
| 122 |
+
|----------|------|-------|
|
| 123 |
+
| **Render** | Starter | $7/mes |
|
| 124 |
+
| **Render Disk** | 5GB | ~$0.50/mes |
|
| 125 |
+
| **Neon** | Free | $0/mes (500MB) |
|
| 126 |
+
| **Total** | | ~$7.50/mes |
|
| 127 |
+
|
| 128 |
+
## Endpoints de la API
|
| 129 |
+
|
| 130 |
+
### Jobs
|
| 131 |
+
- `POST /job` - Crear un nuevo job
|
| 132 |
+
- `GET /jobs` - Listar todos los jobs (con paginación)
|
| 133 |
+
- `GET /job/{job_id}` - Obtener detalles de un job
|
| 134 |
+
- `DELETE /job/{job_id}` - Eliminar un job
|
| 135 |
+
- `GET /download/{job_id}` - Descargar el resultado
|
| 136 |
+
|
| 137 |
+
### Streaming
|
| 138 |
+
- `GET /streaming/{job_id}` - Obtener info de streaming
|
| 139 |
+
- `POST /streaming/create/{job_id}` - Crear sesión de streaming
|
| 140 |
+
- `DELETE /streaming/{job_id}` - Limpiar sesión
|
| 141 |
+
- `WS /stream/{job_id}` - WebSocket para streaming
|
| 142 |
+
|
| 143 |
+
### Sistema
|
| 144 |
+
- `GET /stats` - Estadísticas del sistema
|
| 145 |
+
- `GET /proxy/stats` - Estadísticas de proxies
|
| 146 |
+
- `POST /proxy/reload` - Recargar proxies
|
| 147 |
+
|
| 148 |
+
## Notas Importantes
|
| 149 |
+
|
| 150 |
+
⚠️ **Importante**: Render no soporta el plan free para servicios Docker.
|
| 151 |
+
|
| 152 |
+
⚠️ **Importante**: Los servicios en el plan starter se duermen después de 15 minutos de inactividad. El primer request después de dormir puede tardar ~30 segundos.
|
| 153 |
+
|
| 154 |
+
⚠️ **Importante**: Neon tiene un límite de 500MB en el plan free. Si necesitás más espacio, considerá upgrade a Neon Pro ($19/mes).
|
| 155 |
+
|
| 156 |
+
## Troubleshooting
|
| 157 |
+
|
| 158 |
+
### El deploy falla
|
| 159 |
+
- Verificar los logs en el dashboard de Render
|
| 160 |
+
- Asegurarse de que el Dockerfile sea correcto
|
| 161 |
+
- Verificar que las variables de entorno estén configuradas
|
| 162 |
+
- Checkear que la DATABASE_URL de Neon sea correcta
|
| 163 |
+
|
| 164 |
+
### La aplicación no responde
|
| 165 |
+
- Verificar el health check en el dashboard
|
| 166 |
+
- Revisar los logs de errores
|
| 167 |
+
- Verificar que la GOOGLE_API_KEY sea válida
|
| 168 |
+
- Verificar que la conexión a Neon funcione
|
| 169 |
+
|
| 170 |
+
### Problemas de memoria
|
| 171 |
+
- El plan starter tiene 512MB RAM
|
| 172 |
+
- Si necesitás más, upgrade al plan Standard ($15/mes)
|
| 173 |
+
|
| 174 |
+
### La base de datos no conecta
|
| 175 |
+
- Verificar que la DATABASE_URL incluya `?sslmode=require`
|
| 176 |
+
- Checkear que la región de Neon sea la misma que Render (us-west-2)
|
| 177 |
+
- Verificar los logs de error de conexión
|
Dockerfile
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage Dockerfile for BrowserPilot
|
| 2 |
+
# Stage 1: Build the React frontend
|
| 3 |
+
FROM node:20-alpine AS frontend-builder
|
| 4 |
+
|
| 5 |
+
# Set working directory for frontend
|
| 6 |
+
WORKDIR /app/frontend
|
| 7 |
+
|
| 8 |
+
# Copy package files
|
| 9 |
+
COPY frontend/package*.json ./
|
| 10 |
+
|
| 11 |
+
# Install all dependencies (including dev dependencies needed for build)
|
| 12 |
+
RUN npm config set strict-ssl false && npm install
|
| 13 |
+
|
| 14 |
+
# Copy frontend source code
|
| 15 |
+
COPY frontend/ ./
|
| 16 |
+
|
| 17 |
+
# Build the frontend
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# Stage 2: Use Playwright's official Docker image with Python (Ubuntu-based)
|
| 21 |
+
FROM mcr.microsoft.com/playwright/python:v1.53.0-jammy
|
| 22 |
+
|
| 23 |
+
# Set working directory
|
| 24 |
+
WORKDIR /app
|
| 25 |
+
|
| 26 |
+
# Copy Python requirements and install dependencies
|
| 27 |
+
COPY requirements.txt .
|
| 28 |
+
# Install compatible versions of numpy and pandas for Python 3.10
|
| 29 |
+
RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir \
|
| 30 |
+
fastapi==0.111.0 \
|
| 31 |
+
uvicorn[standard]==0.29.0 \
|
| 32 |
+
playwright==1.53.0 \
|
| 33 |
+
google-generativeai==0.5.0 \
|
| 34 |
+
pydantic==2.7.1 \
|
| 35 |
+
bs4==0.0.2 \
|
| 36 |
+
lxml==5.2.1 \
|
| 37 |
+
markdownify==0.11.6 \
|
| 38 |
+
"numpy>=2.0.0,<2.3.0" \
|
| 39 |
+
"pandas>=2.0.0,<2.3.0" \
|
| 40 |
+
python-dateutil==2.9.0.post0 \
|
| 41 |
+
pytz==2025.2 \
|
| 42 |
+
tzdata==2025.2 \
|
| 43 |
+
reportlab==4.4.2 \
|
| 44 |
+
psycopg2-binary==2.9.9 \
|
| 45 |
+
asyncpg==0.29.0 \
|
| 46 |
+
python-telegram-bot==21.0 \
|
| 47 |
+
aiohttp==3.9.5
|
| 48 |
+
|
| 49 |
+
# Copy backend source code
|
| 50 |
+
COPY backend/ ./backend/
|
| 51 |
+
|
| 52 |
+
# Copy built frontend from the frontend-builder stage
|
| 53 |
+
COPY --from=frontend-builder /app/frontend/dist ./frontend/
|
| 54 |
+
|
| 55 |
+
# Create outputs directory
|
| 56 |
+
RUN mkdir -p outputs
|
| 57 |
+
|
| 58 |
+
# Copy entrypoint script
|
| 59 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 60 |
+
RUN chmod +x /entrypoint.sh
|
| 61 |
+
|
| 62 |
+
# Set environment variables
|
| 63 |
+
ENV PYTHONPATH=/app
|
| 64 |
+
ENV PYTHONUNBUFFERED=1
|
| 65 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0
|
| 66 |
+
ENV GRADIO_SERVER_PORT=8000
|
| 67 |
+
|
| 68 |
+
# Expose the port the app runs on
|
| 69 |
+
EXPOSE 8000
|
| 70 |
+
|
| 71 |
+
# Create a non-root user for security (the playwright image already has pwuser)
|
| 72 |
+
RUN chown -R pwuser:pwuser /app
|
| 73 |
+
USER pwuser
|
| 74 |
+
|
| 75 |
+
# Health check
|
| 76 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
| 77 |
+
CMD curl -f http://localhost:8000/ || exit 1
|
| 78 |
+
|
| 79 |
+
# Run the application using entrypoint
|
| 80 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
KEEPALIVE.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KeepAlive para Hugging Face Spaces - GitHub Actions
|
| 2 |
+
|
| 3 |
+
## ¿Qué es esto?
|
| 4 |
+
|
| 5 |
+
GitHub Actions ejecuta un workflow cada **13 minutos** que hace ping a tu Hugging Face Space para evitar que se duerma.
|
| 6 |
+
|
| 7 |
+
## Configuración
|
| 8 |
+
|
| 9 |
+
### 1. Agregar el secreto en GitHub
|
| 10 |
+
|
| 11 |
+
1. Andá a tu repo: https://github.com/ncolex/33BrowserPilot
|
| 12 |
+
2. **Settings** → **Secrets and variables** → **Actions**
|
| 13 |
+
3. **New repository secret**
|
| 14 |
+
4. **Name**: `HF_SPACE_URL`
|
| 15 |
+
5. **Value**: `https://huggingface.co/spaces/ncolex/browserpilot`
|
| 16 |
+
6. **Add secret**
|
| 17 |
+
|
| 18 |
+
### 2. ¡Listo!
|
| 19 |
+
|
| 20 |
+
El workflow se ejecuta automáticamente cada 13 minutos.
|
| 21 |
+
|
| 22 |
+
## Verificación
|
| 23 |
+
|
| 24 |
+
- **Actions tab**: https://github.com/ncolex/33BrowserPilot/actions
|
| 25 |
+
- **Workflow**: "Keep HF Space Awake"
|
| 26 |
+
- **Runs**: Cada ~13 minutos
|
| 27 |
+
|
| 28 |
+
## Trigger manual
|
| 29 |
+
|
| 30 |
+
Podés ejecutar manualmente el workflow:
|
| 31 |
+
1. Andá a **Actions** → **Keep HF Space Awake**
|
| 32 |
+
2. **Run workflow** → **Run workflow**
|
| 33 |
+
|
| 34 |
+
## Costo: **$0/mes**
|
| 35 |
+
|
| 36 |
+
- **GitHub Actions**: 2000 minutos/mes gratis
|
| 37 |
+
- **Este workflow**: ~1 minuto/mes (segundos realmente)
|
| 38 |
+
- **Total usado**: <1% del límite gratis
|
| 39 |
+
|
| 40 |
+
## Notas
|
| 41 |
+
|
| 42 |
+
- Los workflows se pueden ejecutar cada 5 minutos como mínimo
|
| 43 |
+
- 13 minutos es un buen balance (no muy frecuente, no muy raro)
|
| 44 |
+
- El workflow falla si el Space está caído (esperado)
|
README.docker.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🐳 Docker Setup for BrowserPilot
|
| 2 |
+
|
| 3 |
+
BrowserPilot can be easily deployed using Docker, which provides a consistent environment with all dependencies pre-installed.
|
| 4 |
+
|
| 5 |
+
## Quick Start with Docker
|
| 6 |
+
|
| 7 |
+
### 1. Using Docker Compose (Recommended)
|
| 8 |
+
|
| 9 |
+
The easiest way to run BrowserPilot is with Docker Compose:
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# Clone the repository
|
| 13 |
+
git clone https://github.com/ai-naymul/BrowserPilot.git
|
| 14 |
+
cd BrowserPilot
|
| 15 |
+
|
| 16 |
+
# Create environment file
|
| 17 |
+
echo 'GOOGLE_API_KEY=your_actual_api_key_here' > .env
|
| 18 |
+
|
| 19 |
+
# Run with Docker Compose
|
| 20 |
+
docker-compose up -d
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
The application will be available at `http://localhost:8000`.
|
| 24 |
+
|
| 25 |
+
### 2. Using Docker Build & Run
|
| 26 |
+
|
| 27 |
+
If you prefer to use Docker directly:
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
# Build the image
|
| 31 |
+
docker build -t browserpilot .
|
| 32 |
+
|
| 33 |
+
# Run the container
|
| 34 |
+
docker run -d \
|
| 35 |
+
--name browserpilot \
|
| 36 |
+
-p 8000:8000 \
|
| 37 |
+
-e GOOGLE_API_KEY=your_actual_api_key_here \
|
| 38 |
+
-v $(pwd)/outputs:/app/outputs \
|
| 39 |
+
--shm-size=2g \
|
| 40 |
+
browserpilot
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Configuration
|
| 44 |
+
|
| 45 |
+
### Environment Variables
|
| 46 |
+
|
| 47 |
+
Set these environment variables for the container:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
# Required
|
| 51 |
+
GOOGLE_API_KEY=your_gemini_api_key_here
|
| 52 |
+
|
| 53 |
+
# Optional - Proxy configuration
|
| 54 |
+
SCRAPER_PROXIES='[{"server": "http://proxy1:port", "username": "user", "password": "pass"}]'
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### Volume Mounts
|
| 58 |
+
|
| 59 |
+
- `./outputs:/app/outputs` - Persist extracted data and downloads
|
| 60 |
+
- `./.env:/app/.env:ro` - Mount environment file (optional)
|
| 61 |
+
|
| 62 |
+
## Production Deployment
|
| 63 |
+
|
| 64 |
+
For production use, use the production Docker Compose configuration:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# Production deployment
|
| 68 |
+
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
This provides:
|
| 72 |
+
- Resource limits (4GB RAM, 2 CPU cores)
|
| 73 |
+
- Proper logging configuration
|
| 74 |
+
- Restart policies
|
| 75 |
+
- Optimized environment settings
|
| 76 |
+
|
| 77 |
+
## Docker Image Details
|
| 78 |
+
|
| 79 |
+
### Multi-Stage Build
|
| 80 |
+
The Dockerfile uses a multi-stage build process:
|
| 81 |
+
1. **Frontend Builder**: Builds the React frontend using Node.js Alpine
|
| 82 |
+
2. **Runtime**: Uses Microsoft's Playwright Python image with all browser dependencies
|
| 83 |
+
|
| 84 |
+
### Features
|
| 85 |
+
- ✅ Pre-installed Playwright browsers (Chromium)
|
| 86 |
+
- ✅ All system dependencies for browser automation
|
| 87 |
+
- ✅ Non-root user for security
|
| 88 |
+
- ✅ Health checks included
|
| 89 |
+
- ✅ Optimized for container environments
|
| 90 |
+
|
| 91 |
+
### Image Size
|
| 92 |
+
The final image is approximately 2.5GB due to:
|
| 93 |
+
- Playwright browsers and dependencies
|
| 94 |
+
- Python runtime and packages
|
| 95 |
+
- System libraries for browser automation
|
| 96 |
+
|
| 97 |
+
## Troubleshooting
|
| 98 |
+
|
| 99 |
+
### Common Issues
|
| 100 |
+
|
| 101 |
+
**Container exits immediately:**
|
| 102 |
+
```bash
|
| 103 |
+
# Check logs
|
| 104 |
+
docker logs browserpilot
|
| 105 |
+
|
| 106 |
+
# Common issue: Missing GOOGLE_API_KEY
|
| 107 |
+
docker run -e GOOGLE_API_KEY=your_key_here browserpilot
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
**Browser crashes or fails:**
|
| 111 |
+
```bash
|
| 112 |
+
# Increase shared memory size
|
| 113 |
+
docker run --shm-size=2g browserpilot
|
| 114 |
+
|
| 115 |
+
# Or with Docker Compose (already configured)
|
| 116 |
+
docker-compose up
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**Permission issues with outputs:**
|
| 120 |
+
```bash
|
| 121 |
+
# Fix output directory permissions
|
| 122 |
+
sudo chown -R $(id -u):$(id -g) outputs/
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### Health Checks
|
| 126 |
+
|
| 127 |
+
The container includes health checks:
|
| 128 |
+
```bash
|
| 129 |
+
# Check container health
|
| 130 |
+
docker inspect --format='{{.State.Health}}' browserpilot
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### Performance Tuning
|
| 134 |
+
|
| 135 |
+
For better performance:
|
| 136 |
+
```bash
|
| 137 |
+
# Increase resources
|
| 138 |
+
docker run \
|
| 139 |
+
--memory=4g \
|
| 140 |
+
--cpus=2 \
|
| 141 |
+
--shm-size=2g \
|
| 142 |
+
browserpilot
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
## Development with Docker
|
| 146 |
+
|
| 147 |
+
### Live Development
|
| 148 |
+
For development with live reload:
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
# Mount source code for development
|
| 152 |
+
docker run -it \
|
| 153 |
+
-p 8000:8000 \
|
| 154 |
+
-v $(pwd)/backend:/app/backend \
|
| 155 |
+
-v $(pwd)/outputs:/app/outputs \
|
| 156 |
+
-e GOOGLE_API_KEY=your_key_here \
|
| 157 |
+
browserpilot \
|
| 158 |
+
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### Building Custom Images
|
| 162 |
+
|
| 163 |
+
To customize the Docker image:
|
| 164 |
+
|
| 165 |
+
```dockerfile
|
| 166 |
+
# Extend the base image
|
| 167 |
+
FROM browserpilot:latest
|
| 168 |
+
|
| 169 |
+
# Add custom dependencies
|
| 170 |
+
RUN pip install your-custom-package
|
| 171 |
+
|
| 172 |
+
# Copy custom configurations
|
| 173 |
+
COPY custom-config.json /app/
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
## Security Considerations
|
| 177 |
+
|
| 178 |
+
- The container runs as a non-root user (`pwuser`)
|
| 179 |
+
- Uses security options for browser sandbox
|
| 180 |
+
- Environment variables are not exposed in the image
|
| 181 |
+
- Secrets should be mounted as files or environment variables
|
| 182 |
+
|
| 183 |
+
## Monitoring and Logging
|
| 184 |
+
|
| 185 |
+
### Logs
|
| 186 |
+
```bash
|
| 187 |
+
# View logs
|
| 188 |
+
docker logs -f browserpilot
|
| 189 |
+
|
| 190 |
+
# With timestamps
|
| 191 |
+
docker logs -t browserpilot
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
### Monitoring
|
| 195 |
+
```bash
|
| 196 |
+
# Resource usage
|
| 197 |
+
docker stats browserpilot
|
| 198 |
+
|
| 199 |
+
# Container inspection
|
| 200 |
+
docker inspect browserpilot
|
| 201 |
+
```
|
README.md
CHANGED
|
@@ -1,10 +1,233 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BrowserPilot
|
| 2 |
+
|
| 3 |
+
> Ever wished you could tell your browser "Hey, go grab all the product prices from that e-commerce site" and it would just... do it? That's exactly what this does, but smarter.
|
| 4 |
+
|
| 5 |
+
[](https://opensource.org/licenses/MIT)
|
| 6 |
+
[](https://www.python.org/downloads/)
|
| 7 |
+
[](http://makeapullrequest.com)
|
| 8 |
+
|
| 9 |
+
## What's This All About?
|
| 10 |
+
|
| 11 |
+
Tired of writing complex scrapers that break every time a website changes its layout? Yeah, me too.
|
| 12 |
+
|
| 13 |
+
This AI-powered browser actually *sees* web pages like you do. It doesn't care if Amazon redesigns their product pages or if LinkedIn adds new anti-bot measures. Just tell it what you want in plain English, and it figures out how to get it.
|
| 14 |
+
|
| 15 |
+
Think of it as having a really smart intern who never gets tired, never makes mistakes, and can handle any website you throw at them - even the ones with annoying CAPTCHAs.
|
| 16 |
+
|
| 17 |
+
## See It In Action
|
| 18 |
+
|
| 19 |
+
Trust me, it's pretty cool watching an AI navigate websites like a human
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
https://github.com/user-attachments/assets/39d2ed68-e121-49b9-817e-2eb5edc25627
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
## Why You'll Love This
|
| 26 |
+
|
| 27 |
+
### It Actually "Sees" Websites
|
| 28 |
+
- Uses Google's Gemini AI to look at pages like you do
|
| 29 |
+
- Automatically figures out if it's looking at Amazon, LinkedIn, or your random blog
|
| 30 |
+
- Clicks the right buttons even when websites change their design
|
| 31 |
+
- Works on literally any website (yes, even the weird ones)
|
| 32 |
+
|
| 33 |
+
### Handles the Annoying Stuff
|
| 34 |
+
- Gets blocked by Cloudflare? No problem, switches proxies automatically
|
| 35 |
+
- Encounters a CAPTCHA? Solves it with AI vision
|
| 36 |
+
- Website thinks it's a bot? Laughs in artificial intelligence
|
| 37 |
+
- Proxy goes down? Switches to a backup faster than you can blink
|
| 38 |
+
|
| 39 |
+
### Gives You Data How You Want It
|
| 40 |
+
- Say "save as PDF" and boom, you get a PDF
|
| 41 |
+
- Ask for CSV and it structures everything perfectly
|
| 42 |
+
- Want JSON? It knows what you mean
|
| 43 |
+
- Organizes everything with timestamps and metadata (because details matter)
|
| 44 |
+
|
| 45 |
+
### Watch It Work Live
|
| 46 |
+
- Stream the browser view in real-time (it's oddly satisfying)
|
| 47 |
+
- Click and type remotely if you need to step in
|
| 48 |
+
- Multiple people can watch the same session
|
| 49 |
+
- Perfect for debugging or just showing off
|
| 50 |
+
|
| 51 |
+
## Getting Started (It's Actually Pretty Easy)
|
| 52 |
+
|
| 53 |
+
### 🐳 Quick Start with Docker (Recommended)
|
| 54 |
+
|
| 55 |
+
The easiest way to run BrowserPilot is with Docker:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
# Clone and start with Docker Compose
|
| 59 |
+
git clone https://github.com/ai-naymul/BrowserPilot.git
|
| 60 |
+
cd BrowserPilot
|
| 61 |
+
echo 'GOOGLE_API_KEY=your_actual_api_key_here' > .env
|
| 62 |
+
docker-compose up -d
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Open `http://localhost:8000` and you're ready to go! 🚀
|
| 66 |
+
|
| 67 |
+
[📖 Full Docker Documentation](README.docker.md)
|
| 68 |
+
|
| 69 |
+
### 💻 Manual Installation
|
| 70 |
+
|
| 71 |
+
### What You'll Need
|
| 72 |
+
- Python 3.8 or newer (check with `python --version`)
|
| 73 |
+
- A Google AI API key (free to get, just sign up at ai.google.dev)
|
| 74 |
+
- Some proxies if you're planning to scrape heavily (optional but recommended)
|
| 75 |
+
|
| 76 |
+
### Let's Get This Running
|
| 77 |
+
|
| 78 |
+
1. **Grab the code**
|
| 79 |
+
```bash
|
| 80 |
+
git clone https://github.com/ai-naymul/BrowserPilot.git
|
| 81 |
+
cd BrowserPilot
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
2. **Install the good stuff**
|
| 85 |
+
```bash
|
| 86 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 87 |
+
uv pip install -r requirements.txt
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
3. **Add your secrets**
|
| 91 |
+
```bash
|
| 92 |
+
# Create a .env file (don't worry, it's gitignored)
|
| 93 |
+
echo 'GOOGLE_API_KEY=your_actual_api_key_here' > .env
|
| 94 |
+
echo 'SCRAPER_PROXIES=[{"server": "http://proxy1:port", "username": "user", "password": "pass"}]' >> .env
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
4. **Fire it up**
|
| 98 |
+
```bash
|
| 99 |
+
python -m uvicorn backend.main:app --reload
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
5. **See the magic**
|
| 103 |
+
Open `http://localhost:8000` and start telling it what to do
|
| 104 |
+
|
| 105 |
+
## Real Examples (Because Everyone Loves Examples)
|
| 106 |
+
|
| 107 |
+
### Just Getting Started
|
| 108 |
+
```javascript
|
| 109 |
+
"Go to Hacker News and save the top stories as JSON"
|
| 110 |
+
```
|
| 111 |
+
That's it. Seriously. It'll figure out the rest.
|
| 112 |
+
|
| 113 |
+
### Shopping for Data
|
| 114 |
+
```javascript
|
| 115 |
+
"Search Amazon for wireless headphones under $100 and export the results to CSV"
|
| 116 |
+
```
|
| 117 |
+
It'll navigate, search, filter, and organize everything nicely for you.
|
| 118 |
+
|
| 119 |
+
### Social Media Intel
|
| 120 |
+
```javascript
|
| 121 |
+
"Go to LinkedIn, find AI engineers in San Francisco, and save their profiles"
|
| 122 |
+
```
|
| 123 |
+
Don't worry, it handles all the login prompts and infinite scroll nonsense.
|
| 124 |
+
|
| 125 |
+
### The Wild West
|
| 126 |
+
```javascript
|
| 127 |
+
"Visit this random e-commerce site and grab all the product prices"
|
| 128 |
+
```
|
| 129 |
+
Even works on sites you've never seen before. That's the beauty of AI vision.
|
| 130 |
+
|
| 131 |
+
## Core Components
|
| 132 |
+
|
| 133 |
+
### Smart Browser Controller
|
| 134 |
+
- Automatic anti-bot detection using AI vision
|
| 135 |
+
- Proxy rotation on detection/blocking
|
| 136 |
+
- CAPTCHA solving capabilities
|
| 137 |
+
- Browser restart with new proxies
|
| 138 |
+
|
| 139 |
+
### Vision Model Integration
|
| 140 |
+
- Dynamic website analysis
|
| 141 |
+
- Anti-bot system detection
|
| 142 |
+
- Element interaction decisions
|
| 143 |
+
- CAPTCHA recognition and solving
|
| 144 |
+
|
| 145 |
+
### Universal Extractor
|
| 146 |
+
- AI-powered content extraction
|
| 147 |
+
- Multiple output format support
|
| 148 |
+
- Structured data organization
|
| 149 |
+
- Metadata preservation
|
| 150 |
+
|
| 151 |
+
### Proxy Management
|
| 152 |
+
- Health tracking and statistics
|
| 153 |
+
- Performance-based selection
|
| 154 |
+
- Site-specific blocking lists
|
| 155 |
+
- Automatic failure recovery
|
| 156 |
+
|
| 157 |
+
## The Cool Technical Stuff
|
| 158 |
+
|
| 159 |
+
### Smart Format Detection
|
| 160 |
+
Just talk to it naturally:
|
| 161 |
+
- "save as PDF" → Gets you a beautiful PDF
|
| 162 |
+
- "export to CSV" → Perfectly structured spreadsheet
|
| 163 |
+
- "give me JSON" → Clean, organized data structure
|
| 164 |
+
|
| 165 |
+
### Anti-Bot Ninja Mode
|
| 166 |
+
- Spots Cloudflare challenges before they even load
|
| 167 |
+
- Solves CAPTCHAs like a human (but faster)
|
| 168 |
+
- Detects rate limits and backs off gracefully
|
| 169 |
+
- Switches identities when websites get suspicious
|
| 170 |
+
|
| 171 |
+
### Dashboard That Actually Helps
|
| 172 |
+
- See which proxies are working (and which ones suck)
|
| 173 |
+
- Watch your browser sessions live
|
| 174 |
+
- Track how much you're spending on AI tokens
|
| 175 |
+
- Performance stats that make sense
|
| 176 |
+
|
| 177 |
+
## Configuration
|
| 178 |
+
|
| 179 |
+
### Proxy Configuration
|
| 180 |
+
```json
|
| 181 |
+
{
|
| 182 |
+
"SCRAPER_PROXIES": [
|
| 183 |
+
{
|
| 184 |
+
"server": "http://proxy1.example.com:8080",
|
| 185 |
+
"username": "user1",
|
| 186 |
+
"password": "pass1",
|
| 187 |
+
"location": "US"
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"server": "http://proxy2.example.com:8080",
|
| 191 |
+
"username": "user2",
|
| 192 |
+
"password": "pass2",
|
| 193 |
+
"location": "EU"
|
| 194 |
+
}
|
| 195 |
+
]
|
| 196 |
+
}
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
### Environment Variables
|
| 200 |
+
```bash
|
| 201 |
+
# Required
|
| 202 |
+
GOOGLE_API_KEY=your_gemini_api_key_here
|
| 203 |
+
|
| 204 |
+
# Optional
|
| 205 |
+
SCRAPER_PROXIES=your_proxy_configuration
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
## Contributors
|
| 209 |
+
|
| 210 |
+
<a href="https://github.com/your-username/your-repo/graphs/contributors">
|
| 211 |
+
<img src="https://contrib.rocks/image?repo=ai-naymul/BrowserPilot" />
|
| 212 |
+
</a>
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
## 🤝 Want to Help Make This Better?
|
| 216 |
+
|
| 217 |
+
Found a bug? Have a crazy idea? Want to add support for your favorite website? I'd love the help!
|
| 218 |
+
|
| 219 |
+
Here's how to jump in:
|
| 220 |
+
1. Fork this repo (there's a button for that)
|
| 221 |
+
2. Create a branch with a name that makes sense (`git checkout -b fix-amazon-pagination`)
|
| 222 |
+
3. Make your changes (and please test them!)
|
| 223 |
+
4. Commit with a message that explains what you did
|
| 224 |
+
5. Push it up and open a pull request
|
| 225 |
+
|
| 226 |
+
For big changes, maybe open an issue first so we can chat about it.
|
| 227 |
+
|
| 228 |
+
## 🙏 Acknowledgments
|
| 229 |
+
|
| 230 |
+
- [Playwright](https://playwright.dev/) for browser automation
|
| 231 |
+
- [Google Gemini](https://ai.google.dev/) for vision AI capabilities
|
| 232 |
+
- [FastAPI](https://fastapi.tiangolo.com/) for the backend framework
|
| 233 |
+
- Open source community for inspiration and tools
|
README_HF.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: BrowserPilot
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# BrowserPilot - AI-Powered Browser Automation
|
| 12 |
+
|
| 13 |
+
Este espacio de Hugging Face ejecuta BrowserPilot, un navegador automatizado con IA que usa Google Gemini para navegar sitios web y extraer datos.
|
| 14 |
+
|
| 15 |
+
## Cómo usar
|
| 16 |
+
|
| 17 |
+
1. Agregá tu `GOOGLE_API_KEY` en los **Settings** de este Space (Variables and secrets)
|
| 18 |
+
2. Opcional: agregá `DATABASE_URL` de Neon para guardar el historial de jobs
|
| 19 |
+
3. El frontend estará disponible en la URL de este Space
|
| 20 |
+
|
| 21 |
+
## Endpoints de la API
|
| 22 |
+
|
| 23 |
+
- `POST /job` - Crear un nuevo job
|
| 24 |
+
- `GET /jobs` - Listar todos los jobs
|
| 25 |
+
- `GET /download/{job_id}` - Descargar resultado
|
| 26 |
+
- `WS /stream/{job_id}` - Streaming del navegador
|
| 27 |
+
|
| 28 |
+
## Ver código fuente
|
| 29 |
+
|
| 30 |
+
https://github.com/ncolex/33BrowserPilot
|
SETUP_COMPLETE.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 BrowserPilot - Configuración Lista
|
| 2 |
+
|
| 3 |
+
## ✅ Lo que ya está hecho:
|
| 4 |
+
|
| 5 |
+
1. **Base de datos Neon creada**
|
| 6 |
+
- Proyecto: `browserpilot-db`
|
| 7 |
+
- ID: `late-king-86238640`
|
| 8 |
+
- Región: `aws-us-east-1`
|
| 9 |
+
- ✅ Conectada y lista para usar
|
| 10 |
+
|
| 11 |
+
2. **Código actualizado en GitHub**
|
| 12 |
+
- Repo: https://github.com/ncolex/33BrowserPilot
|
| 13 |
+
- ✅ Con integración a PostgreSQL
|
| 14 |
+
- ✅ Endpoints para listar jobs: `GET /jobs`
|
| 15 |
+
|
| 16 |
+
3. **Variables configuradas localmente**
|
| 17 |
+
- `GOOGLE_API_KEY`: ✅ Configurada
|
| 18 |
+
- `DATABASE_URL`: ✅ Configurada
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## 📋 Lo que tenés que hacer AHORA en Hugging Face:
|
| 23 |
+
|
| 24 |
+
### Paso 1: Crear el Space
|
| 25 |
+
1. Entrá a https://huggingface.co/spaces
|
| 26 |
+
2. Click en **"Create new Space"**
|
| 27 |
+
3. Completar:
|
| 28 |
+
- **Space name**: `browserpilot`
|
| 29 |
+
- **License**: MIT
|
| 30 |
+
- **SDK**: **Docker** ⚠️ (¡importante!)
|
| 31 |
+
- **Visibility**: Public
|
| 32 |
+
4. **Create Space**
|
| 33 |
+
|
| 34 |
+
### Paso 2: Importar desde GitHub
|
| 35 |
+
1. En el Space, click en **"Import from GitHub repo"**
|
| 36 |
+
2. Autorizá Hugging Face a acceder a tu GitHub
|
| 37 |
+
3. Seleccioná: `ncolex/33BrowserPilot`
|
| 38 |
+
4. **Import**
|
| 39 |
+
|
| 40 |
+
### Paso 3: Configurar Variables (Settings → Variables and secrets)
|
| 41 |
+
|
| 42 |
+
**Secrets:**
|
| 43 |
+
```
|
| 44 |
+
GOOGLE_API_KEY = AIzaSyBDl98MXre7ecN9jW16EVltBlaf38awkdo
|
| 45 |
+
DATABASE_URL = postgresql://neondb_owner:npg_obFVM76KLule@ep-twilight-field-aiwca4xu.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
**Variables:**
|
| 49 |
+
```
|
| 50 |
+
PYTHONUNBUFFERED = 1
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Paso 4: Esperar el deploy
|
| 54 |
+
- El build tarda **5-10 minutos**
|
| 55 |
+
- Cuando diga **"Running"**, ¡está listo!
|
| 56 |
+
- URL: `https://huggingface.co/spaces/tu-usuario/browserpilot`
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 🎯 URLs útiles:
|
| 61 |
+
|
| 62 |
+
| Servicio | URL |
|
| 63 |
+
|----------|-----|
|
| 64 |
+
| **GitHub Repo** | https://github.com/ncolex/33BrowserPilot |
|
| 65 |
+
| **Neon Dashboard** | https://console.neon.tech |
|
| 66 |
+
| **Hugging Face Spaces** | https://huggingface.co/spaces |
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## 📊 Endpoints de la API (una vez desplegado):
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Crear un job
|
| 74 |
+
curl -X POST https://huggingface.co/spaces/tu-usuario/browserpilot/job \
|
| 75 |
+
-H "Content-Type: application/json" \
|
| 76 |
+
-d '{"prompt": "Go to Hacker News and save top stories as JSON", "format": "json"}'
|
| 77 |
+
|
| 78 |
+
# Listar jobs
|
| 79 |
+
curl https://huggingface.co/spaces/tu-usuario/browserpilot/jobs
|
| 80 |
+
|
| 81 |
+
# Ver stats
|
| 82 |
+
curl https://huggingface.co/spaces/tu-usuario/browserpilot/stats
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## 💰 Costo: $0/mes
|
| 88 |
+
|
| 89 |
+
- **Hugging Face Spaces**: 100% gratis
|
| 90 |
+
- **Neon Database**: 100% gratis (500MB)
|
| 91 |
+
- **Total**: $0/mes
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## ⚠️ Importante:
|
| 96 |
+
|
| 97 |
+
1. Los **Secrets** en HF no se ven en la UI, solo se usan internamente
|
| 98 |
+
2. La **DATABASE_URL** guarda el historial de jobs permanentemente
|
| 99 |
+
3. Los archivos en `/outputs` **NO persisten** si el Space se reinicia
|
| 100 |
+
4. El Space se **duerme** después de ~48h de inactividad
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## 🔧 Comandos útiles de Neon CLI:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
# Ver proyectos
|
| 108 |
+
neonctl projects list
|
| 109 |
+
|
| 110 |
+
# Ver connection string
|
| 111 |
+
neonctl connection-string --project-id late-king-86238640
|
| 112 |
+
|
| 113 |
+
# Ver branches
|
| 114 |
+
neonctl branches list --project-id late-king-86238640
|
| 115 |
+
|
| 116 |
+
# Ver queries (si está habilitado)
|
| 117 |
+
neonctl operations list --project-id late-king-86238640
|
| 118 |
+
```
|
TELEGRAM_BOT.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🤖 Telegram Bot Integration
|
| 2 |
+
|
| 3 |
+
## Bot: @Error33yobot
|
| 4 |
+
|
| 5 |
+
### Configuración Requerida
|
| 6 |
+
|
| 7 |
+
**En Hugging Face Spaces** (Settings → Variables and secrets):
|
| 8 |
+
|
| 9 |
+
| Type | Key | Value |
|
| 10 |
+
|------|-----|-------|
|
| 11 |
+
| Secret | `TELEGRAM_BOT_TOKEN` | `8246977072:AAHItyl6wYcrkNwTcj5Teu0lsVwTeMKZZqU` |
|
| 12 |
+
| Secret | `TELEGRAM_CHAT_ID` | `5858991844` |
|
| 13 |
+
|
| 14 |
+
**En GitHub Secrets** (Settings → Secrets and variables → Actions):
|
| 15 |
+
|
| 16 |
+
| Secret | Value |
|
| 17 |
+
|--------|-------|
|
| 18 |
+
| `TELEGRAM_BOT_TOKEN` | `8246977072:AAHItyl6wYcrkNwTcj5Teu0lsVwTeMKZZqU` |
|
| 19 |
+
| `TELEGRAM_CHAT_ID` | `5858991844` |
|
| 20 |
+
|
| 21 |
+
✅ **Configuración completa** - ¡El bot ya está funcionando!
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Cómo obtener tu Chat ID (ya configurado)
|
| 26 |
+
|
| 27 |
+
El Chat ID ya está configurado: `5858991844`
|
| 28 |
+
|
| 29 |
+
Si necesitás cambiarlo en el futuro, los pasos son:
|
| 30 |
+
|
| 31 |
+
1. **Abrí Telegram**
|
| 32 |
+
2. **Buscá**: `@Error33yobot`
|
| 33 |
+
3. **Iniciá el bot**: Click en "Start" o mandale "hola"
|
| 34 |
+
4. **Ejecutá este comando**:
|
| 35 |
+
```bash
|
| 36 |
+
curl -s "https://api.telegram.org/bot8246977072:AAHItyl6wYcrkNwTcj5Teu0lsVwTeMKZZqU/getUpdates" | python3 -m json.tool
|
| 37 |
+
```
|
| 38 |
+
5. **Buscá** en el resultado: `"chat": {"id": 123456789, ...}`
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Comandos del Bot
|
| 43 |
+
|
| 44 |
+
| Comando | Descripción |
|
| 45 |
+
|---------|-------------|
|
| 46 |
+
| `/start` | Mostrar ayuda y comandos disponibles |
|
| 47 |
+
| `/ping` | Verificar que el bot está activo |
|
| 48 |
+
| `/status` | Ver estado del sistema (jobs activos, proxies, etc.) |
|
| 49 |
+
| `/jobs` | Listar los últimos 5 jobs |
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Crear Jobs desde Telegram
|
| 54 |
+
|
| 55 |
+
Simplemente **enviá un mensaje** al bot con tu tarea:
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
Go to Hacker News and save the top 10 stories as JSON
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
El bot va a:
|
| 62 |
+
1. ✅ Crear el job
|
| 63 |
+
2. 📋 Confirmarte con el Job ID
|
| 64 |
+
3. 🔔 Avisarte cuando termine con el link de descarga
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Notificaciones Automáticas
|
| 69 |
+
|
| 70 |
+
### Job Started
|
| 71 |
+
```
|
| 72 |
+
🚀 Job Started
|
| 73 |
+
ID: abc123...
|
| 74 |
+
Task: Go to Hacker News...
|
| 75 |
+
Format: json
|
| 76 |
+
⏳ Processing...
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Job Completed
|
| 80 |
+
```
|
| 81 |
+
✅ Job Completed!
|
| 82 |
+
ID: abc123...
|
| 83 |
+
Format: json
|
| 84 |
+
📥 Download Result
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Job Failed
|
| 88 |
+
```
|
| 89 |
+
❌ Job Failed
|
| 90 |
+
ID: abc123...
|
| 91 |
+
Error: [error message]
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### KeepAlive Alert
|
| 95 |
+
```
|
| 96 |
+
⚠️ KeepAlive Alert
|
| 97 |
+
🔴 HF Space health check failed!
|
| 98 |
+
Status: 503
|
| 99 |
+
The Space might be sleeping or down.
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### KeepAlive Restored
|
| 103 |
+
```
|
| 104 |
+
✅ KeepAlive OK
|
| 105 |
+
🟢 HF Space health check passed!
|
| 106 |
+
Status: 200
|
| 107 |
+
The Space is awake and running.
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Flujo de Uso
|
| 113 |
+
|
| 114 |
+
1. **Configurá** las variables en HF Spaces y GitHub
|
| 115 |
+
2. **Iniciá** el bot en Telegram (`/start`)
|
| 116 |
+
3. **Enviá** tu tarea como mensaje
|
| 117 |
+
4. **Esperá** la notificación de completado
|
| 118 |
+
5. **Descargá** el resultado desde el link
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## Ejemplos de Comandos
|
| 123 |
+
|
| 124 |
+
### Buscar en Google
|
| 125 |
+
```
|
| 126 |
+
Search for "AI engineers in San Francisco" on LinkedIn and save as CSV
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### Extraer datos
|
| 130 |
+
```
|
| 131 |
+
Go to amazon.com and get the top 5 wireless headphones under $100
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### Navegación compleja
|
| 135 |
+
```
|
| 136 |
+
Visit hackernews.com, click on "Ask HN", and save all posts from today
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## Troubleshooting
|
| 142 |
+
|
| 143 |
+
### El bot no responde
|
| 144 |
+
- Verificá que `TELEGRAM_BOT_TOKEN` esté configurado
|
| 145 |
+
- Verificá que `TELEGRAM_CHAT_ID` sea correcto
|
| 146 |
+
- Revisá los logs del Space
|
| 147 |
+
|
| 148 |
+
### No llegan las notificaciones
|
| 149 |
+
- Verificá que el bot tenga permisos para enviarte mensajes
|
| 150 |
+
- Asegurate de haber iniciado el bot (`/start`)
|
| 151 |
+
|
| 152 |
+
### El keepalive no notifica
|
| 153 |
+
- Verificá los secrets de GitHub Actions
|
| 154 |
+
- Revisá el workflow en: https://github.com/ncolex/33BrowserPilot/actions
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## Costo: **$0/mes**
|
| 159 |
+
|
| 160 |
+
- Telegram Bot API: Gratis
|
| 161 |
+
- GitHub Actions: 2000 min/mes (usás ~1)
|
| 162 |
+
- Total: $0
|
docker-compose.prod.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
browserpilot:
|
| 5 |
+
restart: always
|
| 6 |
+
# Resource limits for production
|
| 7 |
+
deploy:
|
| 8 |
+
resources:
|
| 9 |
+
limits:
|
| 10 |
+
memory: 4G
|
| 11 |
+
cpus: '2.0'
|
| 12 |
+
reservations:
|
| 13 |
+
memory: 2G
|
| 14 |
+
cpus: '1.0'
|
| 15 |
+
# Production logging
|
| 16 |
+
logging:
|
| 17 |
+
driver: "json-file"
|
| 18 |
+
options:
|
| 19 |
+
max-size: "100m"
|
| 20 |
+
max-file: "10"
|
| 21 |
+
# Don't mount local .env in production
|
| 22 |
+
volumes:
|
| 23 |
+
- ./outputs:/app/outputs
|
| 24 |
+
# Production environment variables
|
| 25 |
+
environment:
|
| 26 |
+
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
| 27 |
+
- SCRAPER_PROXIES=${SCRAPER_PROXIES:-[]}
|
| 28 |
+
# Production settings
|
| 29 |
+
- PYTHONUNBUFFERED=1
|
| 30 |
+
- PYTHONOPTIMIZE=1
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
browserpilot:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "8000:8000"
|
| 10 |
+
environment:
|
| 11 |
+
# Required: Add your Google AI API key
|
| 12 |
+
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
| 13 |
+
# Optional: Proxy configuration
|
| 14 |
+
- SCRAPER_PROXIES=${SCRAPER_PROXIES:-[]}
|
| 15 |
+
volumes:
|
| 16 |
+
# Persist outputs directory
|
| 17 |
+
- ./outputs:/app/outputs
|
| 18 |
+
# Optional: Mount .env file for local development
|
| 19 |
+
- ./.env:/app/.env:ro
|
| 20 |
+
restart: unless-stopped
|
| 21 |
+
# Use SHM size to prevent browser crashes
|
| 22 |
+
shm_size: '2gb'
|
| 23 |
+
# Security options for running browsers
|
| 24 |
+
security_opt:
|
| 25 |
+
- seccomp:unconfined
|
| 26 |
+
healthcheck:
|
| 27 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
|
| 28 |
+
interval: 30s
|
| 29 |
+
timeout: 10s
|
| 30 |
+
retries: 3
|
| 31 |
+
start_period: 40s
|
| 32 |
+
|
| 33 |
+
networks:
|
| 34 |
+
default:
|
| 35 |
+
name: browserpilot-network
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "🚀 Starting BrowserPilot on Hugging Face Spaces..."
|
| 5 |
+
|
| 6 |
+
# Create outputs directory if it doesn't exist
|
| 7 |
+
mkdir -p /app/outputs
|
| 8 |
+
|
| 9 |
+
# Install dependencies if needed
|
| 10 |
+
if [ -f requirements.txt ]; then
|
| 11 |
+
echo "📦 Installing Python dependencies..."
|
| 12 |
+
pip install -q --no-cache-dir -r requirements.txt
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
+
# Check if GOOGLE_API_KEY is set
|
| 16 |
+
if [ -z "$GOOGLE_API_KEY" ]; then
|
| 17 |
+
echo "⚠️ WARNING: GOOGLE_API_KEY is not set!"
|
| 18 |
+
echo "Please add it in Settings → Variables and secrets"
|
| 19 |
+
fi
|
| 20 |
+
|
| 21 |
+
# Check if DATABASE_URL is set (optional)
|
| 22 |
+
if [ -n "$DATABASE_URL" ]; then
|
| 23 |
+
echo "✅ Database configured"
|
| 24 |
+
else
|
| 25 |
+
echo "ℹ️ DATABASE_URL not set - database features disabled"
|
| 26 |
+
fi
|
| 27 |
+
|
| 28 |
+
# Start the application
|
| 29 |
+
echo "🌐 Starting FastAPI server on port 8000..."
|
| 30 |
+
exec python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
| 25 |
+
.env
|
frontend/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
new-ui-browserpilot
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js';
|
| 2 |
+
import globals from 'globals';
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks';
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh';
|
| 5 |
+
import tseslint from 'typescript-eslint';
|
| 6 |
+
|
| 7 |
+
export default tseslint.config(
|
| 8 |
+
{ ignores: ['dist'] },
|
| 9 |
+
{
|
| 10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
languageOptions: {
|
| 13 |
+
ecmaVersion: 2020,
|
| 14 |
+
globals: globals.browser,
|
| 15 |
+
},
|
| 16 |
+
plugins: {
|
| 17 |
+
'react-hooks': reactHooks,
|
| 18 |
+
'react-refresh': reactRefresh,
|
| 19 |
+
},
|
| 20 |
+
rules: {
|
| 21 |
+
...reactHooks.configs.recommended.rules,
|
| 22 |
+
'react-refresh/only-export-components': [
|
| 23 |
+
'warn',
|
| 24 |
+
{ allowConstantExport: true },
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
}
|
| 28 |
+
);
|
frontend/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>BrowserPilot - AI-Powered Web Automation</title>
|
| 8 |
+
<meta name="description" content="Professional AI browser automation dashboard for intelligent web scraping and data extraction">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
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,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "vite-react-typescript-starter",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"lucide-react": "^0.344.0",
|
| 14 |
+
"react": "^18.3.1",
|
| 15 |
+
"react-dom": "^18.3.1"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@eslint/js": "^9.9.1",
|
| 19 |
+
"@types/react": "^18.3.5",
|
| 20 |
+
"@types/react-dom": "^18.3.0",
|
| 21 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 22 |
+
"autoprefixer": "^10.4.18",
|
| 23 |
+
"eslint": "^9.9.1",
|
| 24 |
+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.4.11",
|
| 26 |
+
"globals": "^15.9.0",
|
| 27 |
+
"postcss": "^8.4.35",
|
| 28 |
+
"tailwindcss": "^3.4.1",
|
| 29 |
+
"typescript": "^5.5.3",
|
| 30 |
+
"typescript-eslint": "^8.3.0",
|
| 31 |
+
"vite": "^5.4.2"
|
| 32 |
+
}
|
| 33 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserPilotDashboard } from './components/BrowserPilotDashboard';
|
| 3 |
+
|
| 4 |
+
function App() {
|
| 5 |
+
return <BrowserPilotDashboard />;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default App;
|
frontend/src/components/BrowserPilotDashboard.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Header } from './Header';
|
| 3 |
+
import { JobForm } from './JobForm';
|
| 4 |
+
import { StatusDisplay } from './StatusDisplay';
|
| 5 |
+
import { TokenUsage } from './TokenUsage';
|
| 6 |
+
import { DecisionLog } from './DecisionLog';
|
| 7 |
+
import { ScreenshotGallery } from './ScreenshotGallery';
|
| 8 |
+
import { StreamingViewer } from './StreamingViewer';
|
| 9 |
+
import { ProxyStats } from './ProxyStats';
|
| 10 |
+
import { WebSocketManager } from '../services/WebSocketManager';
|
| 11 |
+
|
| 12 |
+
export const BrowserPilotDashboard: React.FC = () => {
|
| 13 |
+
const [wsManager] = useState(() => new WebSocketManager());
|
| 14 |
+
const [status, setStatus] = useState<{
|
| 15 |
+
message: string;
|
| 16 |
+
type: 'success' | 'error' | 'info';
|
| 17 |
+
} | null>(null);
|
| 18 |
+
const [tokenUsage, setTokenUsage] = useState({
|
| 19 |
+
prompt_tokens: 0,
|
| 20 |
+
response_tokens: 0,
|
| 21 |
+
total_tokens: 0,
|
| 22 |
+
api_calls: 0
|
| 23 |
+
});
|
| 24 |
+
const [proxyStats, setProxyStats] = useState({
|
| 25 |
+
available: 0,
|
| 26 |
+
healthy: 0,
|
| 27 |
+
blocked: 0,
|
| 28 |
+
retry_count: 0
|
| 29 |
+
});
|
| 30 |
+
const [decisions, setDecisions] = useState<any[]>([]);
|
| 31 |
+
const [screenshots, setScreenshots] = useState<string[]>([]);
|
| 32 |
+
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
| 33 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 34 |
+
const [streamingEnabled, setStreamingEnabled] = useState(false);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
// Set up WebSocket event listeners
|
| 38 |
+
wsManager.on('connected', () => {
|
| 39 |
+
setStatus({ message: 'Connected to BrowserPilot server', type: 'success' });
|
| 40 |
+
setIsLoading(false);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
wsManager.on('decision', (data: any) => {
|
| 44 |
+
const decision = data.decision || data;
|
| 45 |
+
setDecisions(prev => [...prev, decision]);
|
| 46 |
+
|
| 47 |
+
if (decision.token_usage) {
|
| 48 |
+
updateTokenUsage(decision.token_usage);
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
wsManager.on('screenshot', (data: any) => {
|
| 53 |
+
const screenshot = data.screenshot || data;
|
| 54 |
+
if (typeof screenshot === 'string') {
|
| 55 |
+
setScreenshots(prev => [...prev, screenshot]);
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
wsManager.on('proxy_stats', (data: any) => {
|
| 60 |
+
setProxyStats(data.stats || data);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
wsManager.on('token_usage', (data: any) => {
|
| 64 |
+
updateTokenUsage(data.token_usage || data);
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
wsManager.on('page_info', (data: any) => {
|
| 68 |
+
setStatus({
|
| 69 |
+
message: `Navigating: ${data.url} • Found ${data.interactive_elements} interactive elements`,
|
| 70 |
+
type: 'info'
|
| 71 |
+
});
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
wsManager.on('extraction', (data: any) => {
|
| 75 |
+
if (data.status === 'completed') {
|
| 76 |
+
setStatus({
|
| 77 |
+
message: `Extraction completed successfully in ${data.format?.toUpperCase()} format`,
|
| 78 |
+
type: 'success'
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
wsManager.on('error', (data: any) => {
|
| 84 |
+
setStatus({
|
| 85 |
+
message: data.message || data.error || 'An unexpected error occurred',
|
| 86 |
+
type: 'error'
|
| 87 |
+
});
|
| 88 |
+
setIsLoading(false);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
return () => {
|
| 92 |
+
wsManager.disconnect();
|
| 93 |
+
wsManager.disconnectStream();
|
| 94 |
+
};
|
| 95 |
+
}, [wsManager]);
|
| 96 |
+
|
| 97 |
+
const updateTokenUsage = (usage: any) => {
|
| 98 |
+
setTokenUsage(prev => ({
|
| 99 |
+
prompt_tokens: prev.prompt_tokens + (usage.prompt_tokens || 0),
|
| 100 |
+
response_tokens: prev.response_tokens + (usage.response_tokens || 0),
|
| 101 |
+
total_tokens: prev.total_tokens + (usage.total_tokens || 0),
|
| 102 |
+
api_calls: prev.api_calls + 1
|
| 103 |
+
}));
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const handleJobCreated = (jobData: { jobId: string; streaming: boolean; format: string }) => {
|
| 107 |
+
console.log('Job created:', jobData);
|
| 108 |
+
setCurrentJobId(jobData.jobId);
|
| 109 |
+
setIsLoading(true);
|
| 110 |
+
setStreamingEnabled(jobData.streaming);
|
| 111 |
+
wsManager.connect(jobData.jobId);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
const clearDecisions = () => setDecisions([]);
|
| 115 |
+
const clearScreenshots = () => setScreenshots([]);
|
| 116 |
+
|
| 117 |
+
return (
|
| 118 |
+
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-amber-50/30 to-orange-50/20 dark:from-stone-900 dark:via-stone-800 dark:to-stone-700 transition-all duration-1000">
|
| 119 |
+
<Header />
|
| 120 |
+
|
| 121 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
| 122 |
+
{/* Welcome Animation */}
|
| 123 |
+
<div className="text-center mb-12 animate-in fade-in slide-in-from-top-1 duration-1000">
|
| 124 |
+
<h1 className="text-4xl font-light text-stone-800 dark:text-stone-200 mb-4 tracking-wide">
|
| 125 |
+
Welcome to <span className="font-medium text-stone-700 dark:text-stone-300">BrowserPilot</span>
|
| 126 |
+
</h1>
|
| 127 |
+
<p className="text-stone-600 dark:text-stone-400 text-lg font-light max-w-2xl mx-auto leading-relaxed">
|
| 128 |
+
Open-source alternative to Perplexity Comet and director.ai. Describe what you need, and watch as your browser comes to life.
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
{/* Control Panel */}
|
| 133 |
+
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-200">
|
| 134 |
+
<div className="xl:col-span-2">
|
| 135 |
+
<JobForm wsManager={wsManager} onJobCreated={handleJobCreated} />
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div className="space-y-6">
|
| 139 |
+
<TokenUsage usage={tokenUsage} />
|
| 140 |
+
<ProxyStats stats={proxyStats} />
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Status Display */}
|
| 145 |
+
{status && (
|
| 146 |
+
<div className="animate-in fade-in slide-in-from-top-2 duration-500">
|
| 147 |
+
<StatusDisplay status={status} onDismiss={() => setStatus(null)} />
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
|
| 151 |
+
{/* Loading State */}
|
| 152 |
+
{isLoading && (
|
| 153 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 p-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
| 154 |
+
<div className="flex items-center justify-center space-x-4">
|
| 155 |
+
<div className="w-8 h-8 border-4 border-amber-200 border-t-amber-600 rounded-full animate-spin"></div>
|
| 156 |
+
<p className="text-stone-700 font-medium">BrowserPilot is working...</p>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
+
{/* Browser Streaming */}
|
| 162 |
+
<div className="animate-in fade-in slide-in-from-left-4 duration-700 delay-400">
|
| 163 |
+
<StreamingViewer
|
| 164 |
+
wsManager={wsManager}
|
| 165 |
+
jobId={currentJobId}
|
| 166 |
+
autoConnect={streamingEnabled}
|
| 167 |
+
/>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
{/* Decision Log */}
|
| 171 |
+
<div className="animate-in fade-in slide-in-from-right-4 duration-700 delay-500">
|
| 172 |
+
<DecisionLog decisions={decisions} onClear={clearDecisions} />
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
{/* Screenshot Gallery */}
|
| 176 |
+
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-600">
|
| 177 |
+
<ScreenshotGallery screenshots={screenshots} onClear={clearScreenshots} />
|
| 178 |
+
</div>
|
| 179 |
+
</main>
|
| 180 |
+
|
| 181 |
+
{/* Floating Action Button */}
|
| 182 |
+
<div className="fixed bottom-8 right-8 z-50">
|
| 183 |
+
<button className="w-14 h-14 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-full shadow-lg hover:shadow-xl transform hover:scale-110 transition-all duration-300 flex items-center justify-center group">
|
| 184 |
+
<div className="w-6 h-6 border-2 border-white rounded-sm group-hover:rotate-12 transition-transform duration-300"></div>
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
};
|
frontend/src/components/DecisionLog.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Brain, Trash2, Clock, Target, Lightbulb } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface DecisionLogProps {
|
| 5 |
+
decisions: any[];
|
| 6 |
+
onClear: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const DecisionLog: React.FC<DecisionLogProps> = ({ decisions, onClear }) => {
|
| 10 |
+
return (
|
| 11 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-300 hover:shadow-md hover:bg-white/80">
|
| 12 |
+
<div className="p-6">
|
| 13 |
+
<div className="flex items-center justify-between mb-6">
|
| 14 |
+
<div className="flex items-center space-x-3">
|
| 15 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-400 to-stone-500 dark:from-stone-500 dark:to-stone-600 rounded-xl flex items-center justify-center shadow-md">
|
| 16 |
+
<Brain className="w-5 h-5 text-white" />
|
| 17 |
+
</div>
|
| 18 |
+
<div>
|
| 19 |
+
<h2 className="text-lg font-medium text-stone-800 dark:text-stone-200">AI Decision Log</h2>
|
| 20 |
+
<p className="text-sm text-stone-600 dark:text-stone-400 font-light">Real-time reasoning & actions</p>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<button
|
| 25 |
+
onClick={onClear}
|
| 26 |
+
className="px-4 py-2 text-xs bg-stone-100/80 dark:bg-stone-700/80 text-stone-600 dark:text-stone-400 rounded-lg hover:bg-stone-200/80 dark:hover:bg-stone-600/80 transition-all duration-200 flex items-center space-x-2 group backdrop-blur-sm"
|
| 27 |
+
>
|
| 28 |
+
<Trash2 className="w-3 h-3 group-hover:scale-110 transition-transform duration-200" />
|
| 29 |
+
<span>Clear Log</span>
|
| 30 |
+
</button>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div className="bg-stone-50/80 dark:bg-stone-800/50 backdrop-blur-sm rounded-xl border border-stone-200/60 dark:border-stone-600/60 h-80 overflow-y-auto">
|
| 34 |
+
{decisions.length === 0 ? (
|
| 35 |
+
<div className="flex items-center justify-center h-full text-stone-500 dark:text-stone-400">
|
| 36 |
+
<div className="text-center animate-in fade-in duration-500">
|
| 37 |
+
<Lightbulb className="w-12 h-12 text-stone-400 dark:text-stone-500 mx-auto mb-4" />
|
| 38 |
+
<p className="text-sm font-medium mb-2">No decisions yet</p>
|
| 39 |
+
<p className="text-xs text-stone-400 dark:text-stone-500 font-light">AI reasoning will appear here as tasks run</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
) : (
|
| 43 |
+
<div className="p-4 space-y-3">
|
| 44 |
+
{decisions.map((decision, index) => (
|
| 45 |
+
<div
|
| 46 |
+
key={index}
|
| 47 |
+
className="bg-white/80 dark:bg-stone-700/50 backdrop-blur-sm p-4 rounded-xl border-l-4 border-stone-400 dark:border-stone-500 shadow-sm hover:shadow-md transition-all duration-300 animate-in fade-in slide-in-from-bottom-2"
|
| 48 |
+
style={{ animationDelay: `${index * 100}ms` }}
|
| 49 |
+
>
|
| 50 |
+
<div className="flex items-center justify-between mb-3">
|
| 51 |
+
<div className="flex items-center space-x-2">
|
| 52 |
+
<Target className="w-4 h-4 text-stone-600 dark:text-stone-400" />
|
| 53 |
+
<span className="font-medium text-stone-600 dark:text-stone-400 text-sm tracking-wide">
|
| 54 |
+
{decision.action?.toUpperCase() || 'THINKING'}
|
| 55 |
+
</span>
|
| 56 |
+
</div>
|
| 57 |
+
<div className="flex items-center space-x-1 text-xs text-stone-500 dark:text-stone-400">
|
| 58 |
+
<Clock className="w-3 h-3" />
|
| 59 |
+
<span>{new Date().toLocaleTimeString()}</span>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<p className="text-stone-700 dark:text-stone-300 text-sm mb-2 leading-relaxed font-light">
|
| 63 |
+
{decision.reason || 'Processing...'}
|
| 64 |
+
</p>
|
| 65 |
+
{decision.text && (
|
| 66 |
+
<p className="text-xs text-stone-500 dark:text-stone-400 font-mono bg-stone-100/80 dark:bg-stone-600/50 px-3 py-2 rounded-lg backdrop-blur-sm">
|
| 67 |
+
"{decision.text}"
|
| 68 |
+
</p>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
))}
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
};
|
frontend/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Activity, Settings, User, Moon, Sun } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
const BrowserPilotLogo = ({ className = "" }) => (
|
| 7 |
+
<svg
|
| 8 |
+
viewBox="0 0 1024 1024"
|
| 9 |
+
className={`w-full h-full ${className}`}
|
| 10 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 11 |
+
>
|
| 12 |
+
<path d="M0 0 C337.92 0 675.84 0 1024 0 C1024 337.92 1024 675.84 1024 1024 C686.08 1024 348.16 1024 0 1024 C0 686.08 0 348.16 0 0 Z " fill="#FEFEFE" transform="translate(0,0)"/>
|
| 13 |
+
<path d="M0 0 C16.75287235 2.39326748 30.16084399 9.09518011 43 20 C43.73605469 20.59167969 44.47210938 21.18335938 45.23046875 21.79296875 C51.60190269 27.11163563 60.6873028 35.38542461 62 44 C62.66 44 63.32 44 64 44 C64.23074219 44.53367187 64.46148437 45.06734375 64.69921875 45.6171875 C66.52384312 49.78569693 68.43494069 53.85197877 70.5625 57.875 C76.24387517 69.13286341 77.15074725 80.84522004 77.14526367 93.24267578 C77.14862228 94.34301651 77.1519809 95.44335724 77.15544128 96.57704163 C77.16490742 100.19981021 77.16689239 103.82253233 77.16796875 107.4453125 C77.17118495 109.9786684 77.1745493 112.51202412 77.17805481 115.04537964 C77.18403568 120.35284937 77.18593884 125.66030199 77.18530273 130.96777344 C77.18470347 137.06841877 77.1952304 143.16897524 77.2110464 149.26959813 C77.22579799 155.17383676 77.22928804 161.07804081 77.22869301 166.98229599 C77.2298664 169.4771244 77.23425185 171.9719536 77.24202538 174.46677017 C77.33350568 206.8656192 77.33350568 206.8656192 72 220 C71.72414063 220.68964844 71.44828125 221.37929688 71.1640625 222.08984375 C62.12427406 243.45310447 48.26244954 259.85812387 30.66748047 274.58789062 C27.45311344 277.30998417 24.34998247 280.15493026 21.23046875 282.984375 C18.52203531 285.43192606 15.77680541 287.83430427 13.01953125 290.2265625 C11.52433695 291.5395571 10.04488349 292.87052487 8.57421875 294.2109375 C7.76597656 294.94570313 6.95773437 295.68046875 6.125 296.4375 C5.38507813 297.11683594 4.64515625 297.79617188 3.8828125 298.49609375 C2 300 2 300 0 300 C0 201 0 102 0 0 Z " fill="#0F2F52" transform="translate(383,295)"/>
|
| 14 |
+
<path d="M0 0 C1.18980469 -0.01224609 2.37960937 -0.02449219 3.60546875 -0.03710938 C11.31114451 -0.04974163 18.57212145 0.6583843 26.125 2.1875 C26.125 2.8475 26.125 3.5075 26.125 4.1875 C26.99511719 4.29835938 27.86523438 4.40921875 28.76171875 4.5234375 C39.13498909 6.57158611 48.41756383 13.36524831 55.6875 20.8125 C57.48053543 23.77490636 57.85613932 24.78039077 57.125 28.1875 C54.74691804 30.58153675 51.98239531 32.37537509 49.1875 34.25 C48.3728125 34.81654297 47.558125 35.38308594 46.71875 35.96679688 C45.02303097 37.14488915 43.32258798 38.31620863 41.61767578 39.48095703 C37.9246459 42.00928993 34.27695515 44.60039306 30.625 47.1875 C29.21876709 48.17710762 27.81251696 49.16669077 26.40625 50.15625 C21.95413946 53.29891627 17.5377812 56.48994254 13.125 59.6875 C6.62441517 64.39790657 0.0863612 69.05047608 -6.48046875 73.66796875 C-11.6406672 77.30204566 -16.7642894 80.98421401 -21.875 84.6875 C-28.37558483 89.39790657 -34.9136388 94.05047608 -41.48046875 98.66796875 C-46.6406672 102.30204566 -51.7642894 105.98421401 -56.875 109.6875 C-63.372616 114.39575531 -69.90723693 119.04685192 -76.47167969 123.66137695 C-81.7204449 127.35704787 -86.92785214 131.10717754 -92.125 134.875 C-97.5209089 138.77947503 -102.92150187 142.67620283 -108.375 146.5 C-109.3959375 147.21800781 -110.416875 147.93601562 -111.46875 148.67578125 C-113.875 150.1875 -113.875 150.1875 -115.875 150.1875 C-115.92533329 144.96290409 -115.96114875 139.73845654 -115.98486328 134.51367188 C-115.99477398 132.74509211 -116.00830643 130.97652804 -116.02587891 129.20800781 C-116.11596352 119.89219849 -116.01276084 110.67978965 -115.00390625 101.41015625 C-114.92749908 100.69997345 -114.85109192 99.98979065 -114.77236938 99.25808716 C-111.70639993 74.07592452 -97.38310532 50.87093685 -79.875 33.1875 C-78.97201172 32.19363281 -78.97201172 32.19363281 -78.05078125 31.1796875 C-66.51677462 18.68693472 -50.02619612 9.65117207 -34.125 4.25 C-33.26189209 3.95520752 -32.39878418 3.66041504 -31.50952148 3.35668945 C-21.11573359 0.01650263 -10.84289408 0.00583579 0 0 Z " fill="#102F53" transform="translate(498.875,574.8125)"/>
|
| 15 |
+
<path d="M0 0 C0.94994742 -0.00170885 1.89989484 -0.0034177 2.87862855 -0.00517833 C6.07551821 -0.00848393 9.27219998 0.00254812 12.46907043 0.01345825 C14.75519811 0.01402213 17.04132615 0.01370626 19.32745361 0.01257324 C25.54611742 0.01199783 31.76470457 0.02378258 37.98335052 0.03772116 C44.47763544 0.05019719 50.97192198 0.05139654 57.46621704 0.05377197 C68.37050244 0.05928817 79.27475943 0.07179293 90.17903137 0.08963013 C101.41112629 0.10798449 112.64321355 0.12214316 123.87532043 0.13064575 C124.91361264 0.13143392 124.91361264 0.13143392 125.97288045 0.13223802 C129.44537302 0.13484807 132.91786566 0.13737549 136.39035833 0.13986182 C165.20413064 0.16059966 194.01787428 0.1959784 222.83161926 0.24050903 C222.83161926 1.56050903 222.83161926 2.88050903 222.83161926 4.24050903 C221.16941346 5.24790649 219.50141252 6.24573858 217.83161926 7.24050903 C216.15506823 8.9638911 214.63734271 10.79442599 213.08552551 12.63113403 C210.24993653 15.91393029 207.29812799 19.07617856 204.33161926 22.24050903 C203.73397766 22.87843384 203.13633606 23.51635864 202.52058411 24.1736145 C201.26870151 25.50910956 200.01641404 26.8442252 198.76374817 28.1789856 C196.80738954 30.26636141 194.85607132 32.35834787 192.90583801 34.45144653 C191.61031149 35.83951067 190.3147398 37.22753265 189.01911926 38.61550903 C188.43388489 39.24457153 187.84865051 39.87363403 187.24568176 40.52175903 C186.10679542 41.73962854 184.95567059 42.9462009 183.79099655 44.13943291 C181.79295443 46.21616339 181.79295443 46.21616339 180.05415726 48.54667854 C176.87732802 52.35217221 174.37178145 54.86006777 169.47389984 56.06991196 C165.35698172 56.38321585 161.32587844 56.41337311 157.20173645 56.34158325 C155.63216869 56.34723826 154.06261089 56.35678465 152.49308777 56.36990356 C148.25296424 56.39373816 144.01497207 56.36161102 139.77507687 56.31771231 C135.33050187 56.27990013 130.88599918 56.28944137 126.44129944 56.2925415 C118.9831574 56.29061857 111.5258405 56.25359574 104.06794739 56.19412231 C95.44877937 56.12568506 86.83060875 56.10646044 78.21119571 56.11489093 C69.90888518 56.1224174 61.60690894 56.10145438 53.30467987 56.06534195 C49.7752578 56.05011771 46.24598352 56.04373239 42.71652985 56.04424667 C38.56288216 56.04311567 34.41013393 56.01667327 30.2567482 55.97068787 C28.7334108 55.95769299 27.2099744 55.95313748 25.68658829 55.95766068 C23.60560981 55.96236383 21.52720472 55.9362782 19.44654846 55.90234375 C18.28295177 55.89526335 17.11935509 55.88818295 15.92049789 55.88088799 C11.21022401 54.90436524 9.02666672 51.98612036 6.39021301 48.13504028 C3.97239561 43.64480797 1.9587493 39.15619574 0.13630676 34.39675903 C-3.25606544 26.19006221 -7.08179858 18.00385367 -11.27531433 10.1736145 C-12.55220416 7.40969606 -12.4942862 5.23883925 -12.16838074 2.24050903 C-8.16063356 -0.43132242 -4.65265941 -0.03032001 0 0 Z " fill="#113054" transform="translate(504.1683807373047,413.7594909667969)"/>
|
| 16 |
+
<path d="M0 0 C1.22506288 0.00276538 1.22506288 0.00276538 2.4748745 0.00558662 C3.41159492 0.0028373 4.34831535 0.00008797 5.31342125 -0.00274467 C6.34416267 0.00444588 7.3749041 0.01163643 8.43688011 0.01904488 C9.5198185 0.01875282 10.60275688 0.01846077 11.71851158 0.01815987 C15.31140108 0.01955738 18.90408527 0.03513848 22.49693871 0.05078316 C24.98272653 0.05451179 27.46851583 0.05735947 29.95430565 0.0593586 C35.83686706 0.0662244 41.71935222 0.08200019 47.60188234 0.10205758 C54.95490879 0.12658579 62.30794434 0.13734311 69.66100121 0.14843941 C82.78083057 0.16991035 95.90059871 0.20367798 109.02037621 0.24609566 C107.22505512 4.12559282 104.91970566 6.5157086 101.83287621 9.49609566 C97.95694142 13.24735002 94.35376225 17.10489378 90.82579613 21.18457222 C87.8919369 24.53460701 84.8294053 27.76069229 81.77037621 30.99609566 C76.25656484 36.84912361 70.79324027 42.74233414 65.38756371 48.69531441 C64.60639183 49.53707222 63.82521996 50.37883003 63.02037621 51.24609566 C61.98324486 52.54196602 61.98324486 52.54196602 60.92516136 53.86401558 C56.97099627 57.10664365 54.14497315 56.97211761 49.17662621 56.91015816 C47.90655724 56.9173336 47.90655724 56.9173336 46.61083031 56.92465401 C44.8256144 56.92804741 43.04033589 56.91888406 41.25523949 56.89795113 C38.52840198 56.87117447 35.80568001 56.89771464 33.07896996 56.92968941 C31.34067965 56.92638521 29.60239191 56.91997875 27.86412621 56.91015816 C27.05254051 56.92027931 26.24095482 56.93040047 25.40477562 56.94082832 C21.19320196 56.85397974 19.50458267 56.62668178 16.08727074 53.94067574 C14.41550033 51.76121318 13.13666206 49.98637697 12.10631371 47.46875191 C11.82014183 46.7900605 11.53396996 46.11136909 11.23912621 45.41211128 C10.96068871 44.71795113 10.68225121 44.02379097 10.39537621 43.30859566 C9.82131787 41.92387387 9.24581725 40.53974904 8.66881371 39.15625191 C8.39649925 38.49383492 8.1241848 37.83141792 7.84361839 37.14892769 C7.07568897 35.37394492 6.19613562 33.64842383 5.30943871 31.92968941 C4.02037621 29.24609566 4.02037621 29.24609566 4.02037621 26.24609566 C3.36037621 26.24609566 2.70037621 26.24609566 2.02037621 26.24609566 C1.67104027 25.31925972 1.32170433 24.39242378 0.96178246 23.43750191 C0.50674339 22.23996284 0.05170433 21.04242378 -0.41712379 19.80859566 C-0.86958473 18.61363472 -1.32204567 17.41867378 -1.78821754 16.18750191 C-2.69410639 13.40163842 -2.69410639 13.40163842 -3.97962379 12.24609566 C-4.1041724 10.60379201 -4.15530904 8.9555729 -4.16712379 7.30859566 C-4.18130348 6.41269722 -4.19548317 5.51679878 -4.21009254 4.59375191 C-3.91152265 1.55238735 -3.10866164 0.38645127 0 0 Z " fill="#0F2F52" transform="translate(536.9796237945557,501.75390434265137)"/>
|
| 17 |
+
</svg>
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
export const Header: React.FC = () => {
|
| 22 |
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
| 23 |
+
const [isDarkMode, setIsDarkMode] = useState(false);
|
| 24 |
+
|
| 25 |
+
const toggleDarkMode = () => {
|
| 26 |
+
setIsDarkMode(!isDarkMode);
|
| 27 |
+
document.documentElement.classList.toggle('dark');
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<header className="bg-white/80 dark:bg-stone-900/80 backdrop-blur-md shadow-sm border-b border-stone-200/60 dark:border-stone-700/60 sticky top-0 z-50 transition-all duration-300">
|
| 32 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 33 |
+
<div className="flex items-center justify-between h-16">
|
| 34 |
+
<div className="flex items-center space-x-4 group">
|
| 35 |
+
{/* Logo Placeholder - You can replace this with your SVG */}
|
| 36 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-700 dark:to-stone-800 rounded-xl flex items-center justify-center shadow-md group-hover:shadow-lg transition-all duration-300 group-hover:scale-105 p-1">
|
| 37 |
+
<BrowserPilotLogo className="group-hover:scale-110 transition-transform duration-300" />
|
| 38 |
+
</div>
|
| 39 |
+
<div className="transform group-hover:translate-x-1 transition-transform duration-300">
|
| 40 |
+
<h1 className="text-xl font-medium text-stone-800 dark:text-stone-200 tracking-wide">BrowserPilot</h1>
|
| 41 |
+
<p className="text-xs text-stone-500 dark:text-stone-400 -mt-1 font-light">Open-source alternative to Perplexity Comet</p>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div className="flex items-center space-x-6">
|
| 46 |
+
{/* Status Indicator */}
|
| 47 |
+
<div className="flex items-center space-x-3 px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-full border border-stone-200/60 dark:border-stone-700/60 hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors duration-200">
|
| 48 |
+
<Activity className="w-4 h-4 text-emerald-500 animate-pulse" />
|
| 49 |
+
<span className="text-sm text-stone-600 dark:text-stone-300 font-medium">Active</span>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
{/* Dark Mode Toggle */}
|
| 53 |
+
<button
|
| 54 |
+
onClick={toggleDarkMode}
|
| 55 |
+
className="w-10 h-10 bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 rounded-full flex items-center justify-center transition-all duration-200 hover:scale-105"
|
| 56 |
+
>
|
| 57 |
+
{isDarkMode ? (
|
| 58 |
+
<Sun className="w-5 h-5 text-stone-600 dark:text-stone-300" />
|
| 59 |
+
) : (
|
| 60 |
+
<Moon className="w-5 h-5 text-stone-600 dark:text-stone-300" />
|
| 61 |
+
)}
|
| 62 |
+
</button>
|
| 63 |
+
|
| 64 |
+
{/* User Menu */}
|
| 65 |
+
<div className="relative">
|
| 66 |
+
<button
|
| 67 |
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
| 68 |
+
className="w-10 h-10 bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 rounded-full flex items-center justify-center transition-all duration-200 hover:scale-105"
|
| 69 |
+
>
|
| 70 |
+
<User className="w-5 h-5 text-stone-600 dark:text-stone-300" />
|
| 71 |
+
</button>
|
| 72 |
+
|
| 73 |
+
{isMenuOpen && (
|
| 74 |
+
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-stone-800 rounded-xl shadow-lg border border-stone-200/60 dark:border-stone-700/60 py-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
| 75 |
+
<button className="w-full px-4 py-2 text-left text-stone-700 dark:text-stone-300 hover:bg-stone-50 dark:hover:bg-stone-700 transition-colors duration-150 flex items-center space-x-3">
|
| 76 |
+
<Settings className="w-4 h-4" />
|
| 77 |
+
<span>Settings</span>
|
| 78 |
+
</button>
|
| 79 |
+
<a
|
| 80 |
+
href="https://browserpilot-alpha.vercel.app/"
|
| 81 |
+
target="_blank"
|
| 82 |
+
rel="noopener noreferrer"
|
| 83 |
+
className="w-full px-4 py-2 text-left text-stone-700 dark:text-stone-300 hover:bg-stone-50 dark:hover:bg-stone-700 transition-colors duration-150 flex items-center space-x-3"
|
| 84 |
+
>
|
| 85 |
+
<Activity className="w-4 h-4" />
|
| 86 |
+
<span>Visit Landing Page</span>
|
| 87 |
+
</a>
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</header>
|
| 95 |
+
);
|
| 96 |
+
};
|
frontend/src/components/JobForm.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Play, Download, Monitor, Eye, EyeOff, Sparkles, ArrowRight } from 'lucide-react';
|
| 3 |
+
import { WebSocketManager } from '../services/WebSocketManager';
|
| 4 |
+
const API_BASE_URL = 'http://localhost:8000';
|
| 5 |
+
|
| 6 |
+
interface JobFormProps {
|
| 7 |
+
wsManager: WebSocketManager;
|
| 8 |
+
onJobCreated: (data: { jobId: string; streaming: boolean; format: string }) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const JobForm: React.FC<JobFormProps> = ({ wsManager, onJobCreated }) => {
|
| 12 |
+
const [prompt, setPrompt] = useState('Navigate to Hacker News and extract the top 10 stories as JSON with titles, URLs, and scores');
|
| 13 |
+
const [format, setFormat] = useState('json');
|
| 14 |
+
const [headless, setHeadless] = useState(false);
|
| 15 |
+
const [streaming, setStreaming] = useState(true);
|
| 16 |
+
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
| 17 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 18 |
+
const [detectedFormat, setDetectedFormat] = useState<string | null>(null);
|
| 19 |
+
const [isHovered, setIsHovered] = useState(false);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
const detected = detectFormatFromPrompt(prompt);
|
| 23 |
+
setDetectedFormat(detected);
|
| 24 |
+
}, [prompt]);
|
| 25 |
+
|
| 26 |
+
const detectFormatFromPrompt = (text: string): string | null => {
|
| 27 |
+
const lower = text.toLowerCase();
|
| 28 |
+
const patterns = {
|
| 29 |
+
pdf: [/\bpdf\b/, /pdf format/, /save.*pdf/, /as pdf/, /to pdf/],
|
| 30 |
+
csv: [/\bcsv\b/, /csv format/, /save.*csv/, /as csv/, /to csv/],
|
| 31 |
+
json: [/\bjson\b/, /json format/, /save.*json/, /as json/, /to json/],
|
| 32 |
+
html: [/\bhtml\b/, /html format/, /save.*html/, /as html/, /to html/],
|
| 33 |
+
md: [/\bmarkdown\b/, /md format/, /save.*markdown/, /as markdown/, /to md/],
|
| 34 |
+
txt: [/\btext\b/, /txt format/, /save.*text/, /as text/, /to txt/, /plain text/]
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
for (const [fmt, regexes] of Object.entries(patterns)) {
|
| 38 |
+
if (regexes.some(regex => regex.test(lower))) {
|
| 39 |
+
return fmt;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
return null;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 46 |
+
e.preventDefault();
|
| 47 |
+
if (!prompt.trim() || isSubmitting) return;
|
| 48 |
+
|
| 49 |
+
setIsSubmitting(true);
|
| 50 |
+
const finalFormat = detectedFormat || format;
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const response = await fetch(`${API_BASE_URL}/job`, {
|
| 54 |
+
method: 'POST',
|
| 55 |
+
headers: { 'Content-Type': 'application/json' },
|
| 56 |
+
body: JSON.stringify({
|
| 57 |
+
prompt,
|
| 58 |
+
format: finalFormat,
|
| 59 |
+
headless,
|
| 60 |
+
enable_streaming: streaming
|
| 61 |
+
})
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const data = await response.json();
|
| 65 |
+
setCurrentJobId(data.job_id);
|
| 66 |
+
onJobCreated({
|
| 67 |
+
jobId: data.job_id,
|
| 68 |
+
streaming,
|
| 69 |
+
format: finalFormat
|
| 70 |
+
});
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('Error creating job:', error);
|
| 73 |
+
} finally {
|
| 74 |
+
setIsSubmitting(false);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const handleDownload = async () => {
|
| 79 |
+
if (!currentJobId) return;
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
const response = await fetch(`${API_BASE_URL}/download/${currentJobId}`);
|
| 83 |
+
if (response.ok) {
|
| 84 |
+
const blob = await response.blob();
|
| 85 |
+
const url = window.URL.createObjectURL(blob);
|
| 86 |
+
const a = document.createElement('a');
|
| 87 |
+
a.href = url;
|
| 88 |
+
a.download = `browserpilot_result_${currentJobId}.${format}`;
|
| 89 |
+
a.click();
|
| 90 |
+
window.URL.revokeObjectURL(url);
|
| 91 |
+
}
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error('Download error:', error);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div
|
| 99 |
+
className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-500 hover:shadow-md hover:bg-white/80"
|
| 100 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 101 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 102 |
+
>
|
| 103 |
+
<div className="p-8">
|
| 104 |
+
<div className="flex items-center space-x-4 mb-8">
|
| 105 |
+
<div className={`w-12 h-12 bg-gradient-to-br from-amber-400 to-orange-500 rounded-xl flex items-center justify-center shadow-md transition-all duration-300 ${isHovered ? 'scale-110 rotate-3' : ''}`}>
|
| 106 |
+
<Sparkles className="w-6 h-6 text-white" />
|
| 107 |
+
</div>
|
| 108 |
+
<div>
|
| 109 |
+
<h2 className="text-xl font-medium text-stone-800 mb-1">Create Automation Task</h2>
|
| 110 |
+
<p className="text-stone-600 font-light">Describe what you want to accomplish, and BrowserPilot will handle the rest</p>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<form onSubmit={handleSubmit} className="space-y-8">
|
| 115 |
+
{/* Task Description */}
|
| 116 |
+
<div className="space-y-3">
|
| 117 |
+
<label htmlFor="prompt" className="block text-sm font-medium text-stone-700">
|
| 118 |
+
Task Description
|
| 119 |
+
<span className="text-stone-500 font-light ml-2">(natural language works best)</span>
|
| 120 |
+
</label>
|
| 121 |
+
<div className="relative">
|
| 122 |
+
<textarea
|
| 123 |
+
id="prompt"
|
| 124 |
+
value={prompt}
|
| 125 |
+
onChange={(e) => setPrompt(e.target.value)}
|
| 126 |
+
rows={4}
|
| 127 |
+
className="w-full px-4 py-4 border border-stone-300/60 dark:border-stone-600/60 rounded-xl focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition-all duration-200 resize-none text-stone-800 dark:text-stone-200 placeholder-stone-400 dark:placeholder-stone-500 bg-white/50 dark:bg-stone-800/50 backdrop-blur-sm"
|
| 128 |
+
placeholder="Examples: • Navigate to Amazon and find wireless headphones under $100 • Go to LinkedIn and extract AI engineer profiles in San Francisco • Visit news.ycombinator.com and save top stories as JSON"
|
| 129 |
+
/>
|
| 130 |
+
<div className="absolute bottom-3 right-3">
|
| 131 |
+
<ArrowRight className="w-5 h-5 text-stone-400" />
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* Example Prompts */}
|
| 136 |
+
<div className="mt-4">
|
| 137 |
+
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-3">
|
| 138 |
+
Quick Examples
|
| 139 |
+
<span className="text-stone-500 dark:text-stone-400 font-light ml-2">(click to use)</span>
|
| 140 |
+
</label>
|
| 141 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 142 |
+
<button
|
| 143 |
+
type="button"
|
| 144 |
+
onClick={() => setPrompt("Go to https://news.ycombinator.com and save top stories as JSON")}
|
| 145 |
+
className="text-left p-3 bg-stone-50/80 dark:bg-stone-800/50 hover:bg-stone-100/80 dark:hover:bg-stone-700/50 border border-stone-200/60 dark:border-stone-600/60 rounded-lg transition-all duration-200 group backdrop-blur-sm"
|
| 146 |
+
>
|
| 147 |
+
<div className="text-sm font-medium text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100">
|
| 148 |
+
Hacker News Stories
|
| 149 |
+
</div>
|
| 150 |
+
<div className="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
| 151 |
+
Extract top stories as JSON
|
| 152 |
+
</div>
|
| 153 |
+
</button>
|
| 154 |
+
|
| 155 |
+
<button
|
| 156 |
+
type="button"
|
| 157 |
+
onClick={() => setPrompt("Visit firecrawl.dev pricing page and save to PDF format")}
|
| 158 |
+
className="text-left p-3 bg-stone-50/80 dark:bg-stone-800/50 hover:bg-stone-100/80 dark:hover:bg-stone-700/50 border border-stone-200/60 dark:border-stone-600/60 rounded-lg transition-all duration-200 group backdrop-blur-sm"
|
| 159 |
+
>
|
| 160 |
+
<div className="text-sm font-medium text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100">
|
| 161 |
+
Save Page as PDF
|
| 162 |
+
</div>
|
| 163 |
+
<div className="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
| 164 |
+
Convert webpage to PDF
|
| 165 |
+
</div>
|
| 166 |
+
</button>
|
| 167 |
+
|
| 168 |
+
<button
|
| 169 |
+
type="button"
|
| 170 |
+
onClick={() => setPrompt("Search 'AI tools' and export results as CSV")}
|
| 171 |
+
className="text-left p-3 bg-stone-50/80 dark:bg-stone-800/50 hover:bg-stone-100/80 dark:hover:bg-stone-700/50 border border-stone-200/60 dark:border-stone-600/60 rounded-lg transition-all duration-200 group backdrop-blur-sm"
|
| 172 |
+
>
|
| 173 |
+
<div className="text-sm font-medium text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100">
|
| 174 |
+
Search & Export CSV
|
| 175 |
+
</div>
|
| 176 |
+
<div className="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
| 177 |
+
Search results to spreadsheet
|
| 178 |
+
</div>
|
| 179 |
+
</button>
|
| 180 |
+
|
| 181 |
+
<button
|
| 182 |
+
type="button"
|
| 183 |
+
onClick={() => setPrompt("Find AI engineers in San Francisco on LinkedIn and save their profiles")}
|
| 184 |
+
className="text-left p-3 bg-stone-50/80 dark:bg-stone-800/50 hover:bg-stone-100/80 dark:hover:bg-stone-700/50 border border-stone-200/60 dark:border-stone-600/60 rounded-lg transition-all duration-200 group backdrop-blur-sm"
|
| 185 |
+
>
|
| 186 |
+
<div className="text-sm font-medium text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100">
|
| 187 |
+
LinkedIn Profiles
|
| 188 |
+
</div>
|
| 189 |
+
<div className="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
| 190 |
+
Extract professional profiles
|
| 191 |
+
</div>
|
| 192 |
+
</button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* Format Detection Indicator */}
|
| 198 |
+
{detectedFormat && (
|
| 199 |
+
<div className="p-4 bg-amber-50/80 backdrop-blur-sm border border-amber-200/60 rounded-xl animate-in fade-in slide-in-from-top-1 duration-300">
|
| 200 |
+
<div className="flex items-center">
|
| 201 |
+
<Sparkles className="w-4 h-4 text-amber-600 mr-3" />
|
| 202 |
+
<span className="text-sm font-medium text-amber-800">Smart Detection:</span>
|
| 203 |
+
<span className="ml-2 text-sm text-amber-700 font-mono uppercase bg-amber-100 px-2 py-1 rounded-md">
|
| 204 |
+
{detectedFormat} format detected
|
| 205 |
+
</span>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
)}
|
| 209 |
+
|
| 210 |
+
{/* Configuration Grid */}
|
| 211 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 212 |
+
{/* Format Selection */}
|
| 213 |
+
<div className="space-y-3">
|
| 214 |
+
<label htmlFor="format" className="block text-sm font-medium text-stone-700">
|
| 215 |
+
Output Format
|
| 216 |
+
</label>
|
| 217 |
+
<select
|
| 218 |
+
id="format"
|
| 219 |
+
value={format}
|
| 220 |
+
onChange={(e) => setFormat(e.target.value)}
|
| 221 |
+
className="w-full px-4 py-3 border border-stone-300/60 dark:border-stone-600/60 rounded-xl focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition-all duration-200 text-stone-800 dark:text-stone-200 bg-white/50 dark:bg-stone-800/50 backdrop-blur-sm"
|
| 222 |
+
>
|
| 223 |
+
<option value="txt">Plain Text (TXT)</option>
|
| 224 |
+
<option value="md">Markdown (MD)</option>
|
| 225 |
+
<option value="json">JSON</option>
|
| 226 |
+
<option value="html">HTML</option>
|
| 227 |
+
<option value="csv">CSV</option>
|
| 228 |
+
<option value="pdf">PDF</option>
|
| 229 |
+
</select>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
{/* Options */}
|
| 233 |
+
<div className="space-y-4">
|
| 234 |
+
<label className="block text-sm font-medium text-stone-700">Automation Options</label>
|
| 235 |
+
<div className="space-y-4">
|
| 236 |
+
<label className="flex items-center cursor-pointer group">
|
| 237 |
+
<input
|
| 238 |
+
type="checkbox"
|
| 239 |
+
checked={headless}
|
| 240 |
+
onChange={(e) => setHeadless(e.target.checked)}
|
| 241 |
+
className="w-4 h-4 text-amber-600 border-stone-300 dark:border-stone-600 rounded focus:ring-amber-500/20 focus:ring-2 bg-white dark:bg-stone-800"
|
| 242 |
+
/>
|
| 243 |
+
<div className="ml-3 flex items-center">
|
| 244 |
+
{headless ? (
|
| 245 |
+
<EyeOff className="w-4 h-4 text-stone-600 dark:text-stone-400 mr-2" />
|
| 246 |
+
) : (
|
| 247 |
+
<Eye className="w-4 h-4 text-stone-600 dark:text-stone-400 mr-2" />
|
| 248 |
+
)}
|
| 249 |
+
<span className="text-sm text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100 transition-colors">
|
| 250 |
+
Headless Mode
|
| 251 |
+
</span>
|
| 252 |
+
</div>
|
| 253 |
+
</label>
|
| 254 |
+
<label className="flex items-center cursor-pointer group">
|
| 255 |
+
<input
|
| 256 |
+
type="checkbox"
|
| 257 |
+
checked={streaming}
|
| 258 |
+
onChange={(e) => setStreaming(e.target.checked)}
|
| 259 |
+
className="w-4 h-4 text-amber-600 border-stone-300 dark:border-stone-600 rounded focus:ring-amber-500/20 focus:ring-2 bg-white dark:bg-stone-800"
|
| 260 |
+
/>
|
| 261 |
+
<div className="ml-3 flex items-center">
|
| 262 |
+
<Monitor className="w-4 h-4 text-stone-600 dark:text-stone-400 mr-2" />
|
| 263 |
+
<span className="text-sm text-stone-700 dark:text-stone-300 group-hover:text-stone-900 dark:group-hover:text-stone-100 transition-colors">
|
| 264 |
+
Live Streaming
|
| 265 |
+
</span>
|
| 266 |
+
</div>
|
| 267 |
+
</label>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
{/* Action Buttons */}
|
| 273 |
+
<div className="flex flex-wrap gap-4 pt-6 border-t border-stone-200/60">
|
| 274 |
+
<button
|
| 275 |
+
type="submit"
|
| 276 |
+
disabled={isSubmitting || !prompt.trim()}
|
| 277 |
+
className="flex-1 min-w-0 bg-gradient-to-r from-amber-500 to-orange-500 text-white px-8 py-4 rounded-xl font-medium hover:from-amber-600 hover:to-orange-600 focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 flex items-center justify-center space-x-3 group shadow-md hover:shadow-lg"
|
| 278 |
+
>
|
| 279 |
+
<Play className={`w-5 h-5 transition-transform duration-300 ${isSubmitting ? 'animate-spin' : 'group-hover:scale-110'}`} />
|
| 280 |
+
<span className="text-lg">{isSubmitting ? 'Launching BrowserPilot...' : 'Start Automation'}</span>
|
| 281 |
+
</button>
|
| 282 |
+
|
| 283 |
+
<button
|
| 284 |
+
type="button"
|
| 285 |
+
onClick={handleDownload}
|
| 286 |
+
disabled={!currentJobId}
|
| 287 |
+
className="px-8 py-4 border border-stone-300/60 dark:border-stone-600/60 text-stone-700 dark:text-stone-300 rounded-xl font-medium hover:bg-stone-50 dark:hover:bg-stone-700/50 hover:border-stone-400 dark:hover:border-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-500/20 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 flex items-center space-x-3 group bg-white/50 dark:bg-stone-800/50 backdrop-blur-sm"
|
| 288 |
+
>
|
| 289 |
+
<Download className="w-5 h-5 group-hover:scale-110 transition-transform duration-300" />
|
| 290 |
+
<span>Download Results</span>
|
| 291 |
+
</button>
|
| 292 |
+
</div>
|
| 293 |
+
</form>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
);
|
| 297 |
+
};
|
frontend/src/components/ProxyStats.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Shield, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface ProxyStatsProps {
|
| 5 |
+
stats: {
|
| 6 |
+
available: number;
|
| 7 |
+
healthy: number;
|
| 8 |
+
blocked: number;
|
| 9 |
+
retry_count: number;
|
| 10 |
+
};
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const ProxyStats: React.FC<ProxyStatsProps> = ({ stats }) => {
|
| 14 |
+
return (
|
| 15 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-300 hover:shadow-md hover:bg-white/80">
|
| 16 |
+
<div className="p-6">
|
| 17 |
+
<div className="flex items-center space-x-3 mb-6">
|
| 18 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-400 to-stone-500 dark:from-stone-500 dark:to-stone-600 rounded-xl flex items-center justify-center shadow-md">
|
| 19 |
+
<Shield className="w-5 h-5 text-white" />
|
| 20 |
+
</div>
|
| 21 |
+
<div>
|
| 22 |
+
<h2 className="text-lg font-medium text-stone-800 dark:text-stone-200">Proxy Network</h2>
|
| 23 |
+
<p className="text-sm text-stone-600 dark:text-stone-400 font-light">Smart rotation & health</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div className="grid grid-cols-2 gap-4">
|
| 28 |
+
<div className="bg-gradient-to-br from-sage-50/80 to-sage-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-sage-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 29 |
+
<div className="flex items-center justify-between mb-2">
|
| 30 |
+
<CheckCircle className="w-4 h-4 text-sage-600 dark:text-sage-400" />
|
| 31 |
+
<span className="text-xs text-sage-600/70 dark:text-sage-400/70 font-medium tracking-wide">AVAILABLE</span>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="text-2xl font-light text-sage-700 dark:text-sage-300 mb-1">{stats.available}</div>
|
| 34 |
+
<div className="text-sm text-sage-600/80 dark:text-sage-400/80 font-light">Proxies</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="bg-gradient-to-br from-sky-50/80 to-sky-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-sky-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 38 |
+
<div className="flex items-center justify-between mb-2">
|
| 39 |
+
<CheckCircle className="w-4 h-4 text-sky-600 dark:text-sky-400 fill-current" />
|
| 40 |
+
<span className="text-xs text-sky-600/70 dark:text-sky-400/70 font-medium tracking-wide">HEALTHY</span>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="text-2xl font-light text-sky-700 dark:text-sky-300 mb-1">{stats.healthy}</div>
|
| 43 |
+
<div className="text-sm text-sky-600/80 dark:text-sky-400/80 font-light">Active</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="bg-gradient-to-br from-honey-50/80 to-honey-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-honey-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 47 |
+
<div className="flex items-center justify-between mb-2">
|
| 48 |
+
<XCircle className="w-4 h-4 text-honey-600 dark:text-honey-400" />
|
| 49 |
+
<span className="text-xs text-honey-600/70 dark:text-honey-400/70 font-medium tracking-wide">BLOCKED</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="text-2xl font-light text-honey-700 dark:text-honey-300 mb-1">{stats.blocked}</div>
|
| 52 |
+
<div className="text-sm text-honey-600/80 dark:text-honey-400/80 font-light">Proxies</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="bg-gradient-to-br from-blush-50/80 to-blush-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-blush-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 56 |
+
<div className="flex items-center justify-between mb-2">
|
| 57 |
+
<RotateCcw className="w-4 h-4 text-blush-600 dark:text-blush-400" />
|
| 58 |
+
<span className="text-xs text-blush-600/70 dark:text-blush-400/70 font-medium tracking-wide">RETRIES</span>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="text-2xl font-light text-blush-700 dark:text-blush-300 mb-1">{stats.retry_count}</div>
|
| 61 |
+
<div className="text-sm text-blush-600/80 dark:text-blush-400/80 font-light">Count</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
};
|
frontend/src/components/ScreenshotGallery.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react'
|
| 2 |
+
import { Camera, X, Maximize2 } from 'lucide-react'
|
| 3 |
+
|
| 4 |
+
interface ScreenshotGalleryProps {
|
| 5 |
+
screenshots: string[]
|
| 6 |
+
onClear: () => void
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const ScreenshotGallery: React.FC<ScreenshotGalleryProps> = ({ screenshots, onClear }) => {
|
| 10 |
+
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
| 11 |
+
|
| 12 |
+
const handleImageClick = (screenshot: string) => {
|
| 13 |
+
setSelectedImage(screenshot)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const closeModal = () => {
|
| 17 |
+
setSelectedImage(null)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="animate-fade-in-up" style={{ animationDelay: '0.6s' }}>
|
| 22 |
+
<div className="bg-white/80 dark:bg-stone-800/80 backdrop-blur-sm rounded-2xl border border-stone-200/50 dark:border-stone-700/50 shadow-lg hover:shadow-xl transition-all duration-300">
|
| 23 |
+
<div className="p-6">
|
| 24 |
+
<div className="flex items-center justify-between mb-6">
|
| 25 |
+
<div className="flex items-center space-x-3">
|
| 26 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-400 to-stone-500 rounded-xl flex items-center justify-center shadow-sm">
|
| 27 |
+
<Camera className="w-5 h-5 text-white" />
|
| 28 |
+
</div>
|
| 29 |
+
<div>
|
| 30 |
+
<h2 className="text-lg font-semibold text-stone-900 dark:text-stone-100">Screenshots</h2>
|
| 31 |
+
<p className="text-sm text-stone-600 dark:text-stone-400">Captured browser states during automation</p>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div className="flex items-center space-x-3">
|
| 36 |
+
<span className="text-sm text-stone-500 dark:text-stone-400">
|
| 37 |
+
{screenshots.length} screenshot{screenshots.length !== 1 ? 's' : ''}
|
| 38 |
+
</span>
|
| 39 |
+
<button
|
| 40 |
+
onClick={onClear}
|
| 41 |
+
className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-lg hover:bg-stone-200 dark:hover:bg-stone-600 transition-colors duration-200"
|
| 42 |
+
>
|
| 43 |
+
Clear
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto">
|
| 49 |
+
{screenshots.length === 0 ? (
|
| 50 |
+
<div className="col-span-full text-stone-500 dark:text-stone-400 text-center py-12">
|
| 51 |
+
<Camera className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 52 |
+
<p>No screenshots captured yet</p>
|
| 53 |
+
<p className="text-sm mt-1">Screenshots will appear here as the agent runs</p>
|
| 54 |
+
</div>
|
| 55 |
+
) : (
|
| 56 |
+
screenshots.map((screenshot, index) => (
|
| 57 |
+
<div
|
| 58 |
+
key={index}
|
| 59 |
+
className="relative group cursor-pointer transform hover:scale-105 transition-all duration-300"
|
| 60 |
+
onClick={() => handleImageClick(screenshot)}
|
| 61 |
+
>
|
| 62 |
+
<img
|
| 63 |
+
src={`data:image/png;base64,${screenshot}`}
|
| 64 |
+
className="w-full h-32 object-cover rounded-xl border border-stone-200 dark:border-stone-700 shadow-sm group-hover:shadow-md transition-all duration-300"
|
| 65 |
+
alt={`Screenshot ${index + 1}`}
|
| 66 |
+
/>
|
| 67 |
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300 rounded-xl flex items-center justify-center">
|
| 68 |
+
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
| 69 |
+
</div>
|
| 70 |
+
<div className="absolute bottom-2 left-2 right-2">
|
| 71 |
+
<div className="bg-black/70 text-white text-xs px-2 py-1 rounded-lg backdrop-blur-sm">
|
| 72 |
+
Screenshot {index + 1} - {new Date().toLocaleTimeString()}
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
))
|
| 77 |
+
)}
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Modal for full-size image */}
|
| 83 |
+
{selectedImage && (
|
| 84 |
+
<div
|
| 85 |
+
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in"
|
| 86 |
+
onClick={closeModal}
|
| 87 |
+
>
|
| 88 |
+
<div className="relative max-w-4xl max-h-full">
|
| 89 |
+
<button
|
| 90 |
+
onClick={closeModal}
|
| 91 |
+
className="absolute -top-12 right-0 text-white hover:text-stone-300 transition-colors"
|
| 92 |
+
>
|
| 93 |
+
<X className="w-8 h-8" />
|
| 94 |
+
</button>
|
| 95 |
+
<img
|
| 96 |
+
src={`data:image/png;base64,${selectedImage}`}
|
| 97 |
+
className="max-w-full max-h-full object-contain rounded-xl shadow-2xl"
|
| 98 |
+
alt="Full size screenshot"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
)}
|
| 103 |
+
</div>
|
| 104 |
+
)
|
| 105 |
+
}
|
frontend/src/components/StatusDisplay.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { CheckCircle, AlertCircle, Info, X, Sparkles } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface StatusDisplayProps {
|
| 5 |
+
status: {
|
| 6 |
+
message: string;
|
| 7 |
+
type: 'success' | 'error' | 'info';
|
| 8 |
+
};
|
| 9 |
+
onDismiss: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const StatusDisplay: React.FC<StatusDisplayProps> = ({ status, onDismiss }) => {
|
| 13 |
+
const [isVisible, setIsVisible] = useState(true);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
setIsVisible(true);
|
| 17 |
+
const timer = setTimeout(() => {
|
| 18 |
+
setIsVisible(false);
|
| 19 |
+
setTimeout(onDismiss, 300);
|
| 20 |
+
}, 6000);
|
| 21 |
+
|
| 22 |
+
return () => clearTimeout(timer);
|
| 23 |
+
}, [status, onDismiss]);
|
| 24 |
+
|
| 25 |
+
const getStatusConfig = () => {
|
| 26 |
+
switch (status.type) {
|
| 27 |
+
case 'success':
|
| 28 |
+
return {
|
| 29 |
+
icon: CheckCircle,
|
| 30 |
+
bgColor: 'bg-emerald-50/90 border-emerald-200/60',
|
| 31 |
+
textColor: 'text-emerald-800',
|
| 32 |
+
iconColor: 'text-emerald-600',
|
| 33 |
+
accentColor: 'bg-emerald-500'
|
| 34 |
+
};
|
| 35 |
+
case 'error':
|
| 36 |
+
return {
|
| 37 |
+
icon: AlertCircle,
|
| 38 |
+
bgColor: 'bg-rose-50/90 border-rose-200/60',
|
| 39 |
+
textColor: 'text-rose-800',
|
| 40 |
+
iconColor: 'text-rose-600',
|
| 41 |
+
accentColor: 'bg-rose-500'
|
| 42 |
+
};
|
| 43 |
+
default:
|
| 44 |
+
return {
|
| 45 |
+
icon: Info,
|
| 46 |
+
bgColor: 'bg-blue-50/90 border-blue-200/60',
|
| 47 |
+
textColor: 'text-blue-800',
|
| 48 |
+
iconColor: 'text-blue-600',
|
| 49 |
+
accentColor: 'bg-blue-500'
|
| 50 |
+
};
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const config = getStatusConfig();
|
| 55 |
+
const Icon = config.icon;
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div
|
| 59 |
+
className={`
|
| 60 |
+
${config.bgColor} border rounded-xl p-4 shadow-sm backdrop-blur-sm transition-all duration-500 relative overflow-hidden
|
| 61 |
+
${isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-2 scale-95'}
|
| 62 |
+
`}
|
| 63 |
+
>
|
| 64 |
+
{/* Animated accent bar */}
|
| 65 |
+
<div className={`absolute top-0 left-0 h-1 ${config.accentColor} animate-pulse`} style={{ width: '100%' }}></div>
|
| 66 |
+
|
| 67 |
+
<div className="flex items-center justify-between">
|
| 68 |
+
<div className="flex items-center space-x-4">
|
| 69 |
+
<div className="relative">
|
| 70 |
+
<Icon className={`w-6 h-6 ${config.iconColor}`} />
|
| 71 |
+
{status.type === 'info' && (
|
| 72 |
+
<Sparkles className="w-3 h-3 text-blue-400 absolute -top-1 -right-1 animate-pulse" />
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
<div>
|
| 76 |
+
<span className={`text-sm font-medium ${config.textColor} leading-relaxed`}>
|
| 77 |
+
{status.message}
|
| 78 |
+
</span>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<button
|
| 82 |
+
onClick={() => {
|
| 83 |
+
setIsVisible(false);
|
| 84 |
+
setTimeout(onDismiss, 300);
|
| 85 |
+
}}
|
| 86 |
+
className={`${config.textColor} hover:opacity-70 transition-all duration-200 p-1 rounded-lg hover:bg-white/20`}
|
| 87 |
+
>
|
| 88 |
+
<X className="w-4 h-4" />
|
| 89 |
+
</button>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
};
|
frontend/src/components/StreamingViewer.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Monitor, Play, Square, Camera, Settings, Circle, Maximize2 } from 'lucide-react';
|
| 3 |
+
import { WebSocketManager } from '../services/WebSocketManager';
|
| 4 |
+
|
| 5 |
+
interface StreamingViewerProps {
|
| 6 |
+
wsManager: WebSocketManager;
|
| 7 |
+
jobId: string | null;
|
| 8 |
+
autoConnect?: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const StreamingViewer: React.FC<StreamingViewerProps> = ({ wsManager, jobId, autoConnect = false }) => {
|
| 12 |
+
const [isConnected, setIsConnected] = useState(false);
|
| 13 |
+
const [currentFrame, setCurrentFrame] = useState<string | null>(null);
|
| 14 |
+
const [streamStats, setStreamStats] = useState({ frameCount: 0, fps: 0 });
|
| 15 |
+
const [showStream, setShowStream] = useState(false);
|
| 16 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 17 |
+
const lastFrameTimeRef = useRef<number | null>(null);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
wsManager.on('stream_connected', () => {
|
| 21 |
+
setIsConnected(true);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
wsManager.on('stream_disconnected', () => {
|
| 25 |
+
setIsConnected(false);
|
| 26 |
+
setStreamStats({ frameCount: 0, fps: 0 });
|
| 27 |
+
lastFrameTimeRef.current = null;
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
wsManager.on('stream_frame', (data: any) => {
|
| 31 |
+
setCurrentFrame(data.data);
|
| 32 |
+
|
| 33 |
+
const now = performance.now();
|
| 34 |
+
const lastFrame = lastFrameTimeRef.current;
|
| 35 |
+
const fps = lastFrame ? Math.round(1000 / Math.max(now - lastFrame, 1)) : 0;
|
| 36 |
+
lastFrameTimeRef.current = now;
|
| 37 |
+
|
| 38 |
+
setStreamStats(prev => ({
|
| 39 |
+
frameCount: prev.frameCount + 1,
|
| 40 |
+
fps: fps || prev.fps
|
| 41 |
+
}));
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
wsManager.on('streaming_info', (data: any) => {
|
| 45 |
+
if (data.streaming?.enabled) {
|
| 46 |
+
setShowStream(true);
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
}, [wsManager]);
|
| 50 |
+
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
if (autoConnect && jobId && !wsManager.isStreamConnected()) {
|
| 53 |
+
setShowStream(true);
|
| 54 |
+
wsManager.connectStream(jobId);
|
| 55 |
+
} else {
|
| 56 |
+
console.log('Auto-connect conditions not met:', {
|
| 57 |
+
autoConnect: !!autoConnect,
|
| 58 |
+
jobId: !!jobId,
|
| 59 |
+
notConnected: !wsManager.isStreamConnected()
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (!jobId) {
|
| 64 |
+
setCurrentFrame(null);
|
| 65 |
+
setStreamStats({ frameCount: 0, fps: 0 });
|
| 66 |
+
lastFrameTimeRef.current = null;
|
| 67 |
+
setIsConnected(false);
|
| 68 |
+
}
|
| 69 |
+
}, [autoConnect, jobId, wsManager]);
|
| 70 |
+
|
| 71 |
+
const handleConnect = () => {
|
| 72 |
+
if (jobId) {
|
| 73 |
+
wsManager.connectStream(jobId);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleDisconnect = () => {
|
| 78 |
+
wsManager.disconnectStream();
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleStreamClick = (e: React.MouseEvent<HTMLImageElement>) => {
|
| 82 |
+
if (!isConnected) return;
|
| 83 |
+
|
| 84 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
| 85 |
+
const x = Math.round((e.clientX - rect.left) * (1280 / rect.width));
|
| 86 |
+
const y = Math.round((e.clientY - rect.top) * (800 / rect.height));
|
| 87 |
+
|
| 88 |
+
wsManager.sendStreamMessage({
|
| 89 |
+
type: 'mouse',
|
| 90 |
+
eventType: 'mousePressed',
|
| 91 |
+
x,
|
| 92 |
+
y,
|
| 93 |
+
button: 'left',
|
| 94 |
+
clickCount: 1
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
setTimeout(() => {
|
| 98 |
+
wsManager.sendStreamMessage({
|
| 99 |
+
type: 'mouse',
|
| 100 |
+
eventType: 'mouseReleased',
|
| 101 |
+
x,
|
| 102 |
+
y,
|
| 103 |
+
button: 'left'
|
| 104 |
+
});
|
| 105 |
+
}, 100);
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
if (!showStream) {
|
| 109 |
+
return (
|
| 110 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-300 hover:shadow-md hover:bg-white/80">
|
| 111 |
+
<div className="p-8">
|
| 112 |
+
<div className="flex items-center space-x-3 mb-6">
|
| 113 |
+
<div className="w-10 h-10 bg-gradient-to-br from-purple-400 to-pink-500 rounded-xl flex items-center justify-center shadow-md">
|
| 114 |
+
<Monitor className="w-5 h-5 text-white" />
|
| 115 |
+
</div>
|
| 116 |
+
<div>
|
| 117 |
+
<h2 className="text-lg font-medium text-stone-800">Live Browser View</h2>
|
| 118 |
+
<p className="text-sm text-stone-600 font-light">Real-time browser streaming with interaction</p>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="bg-stone-50/80 dark:bg-stone-800/50 backdrop-blur-sm rounded-xl border-2 border-dashed border-stone-300/60 dark:border-stone-600/60 p-16 text-center">
|
| 123 |
+
<div className="w-20 h-20 bg-stone-200/80 dark:bg-stone-700/80 rounded-2xl mx-auto mb-6 flex items-center justify-center">
|
| 124 |
+
<Monitor className="w-10 h-10 text-stone-400 dark:text-stone-500" />
|
| 125 |
+
</div>
|
| 126 |
+
<h3 className="text-xl font-medium text-stone-800 dark:text-stone-200 mb-3">Browser Streaming</h3>
|
| 127 |
+
<p className="text-stone-600 dark:text-stone-400 font-light mb-6 max-w-md mx-auto leading-relaxed">
|
| 128 |
+
Enable streaming to watch BrowserPilot navigate websites in real-time
|
| 129 |
+
</p>
|
| 130 |
+
<button
|
| 131 |
+
onClick={() => setShowStream(true)}
|
| 132 |
+
className="px-6 py-3 bg-gradient-to-r from-stone-500 to-stone-600 text-white rounded-xl hover:from-stone-600 hover:to-stone-700 transition-all duration-300 flex items-center space-x-3 mx-auto group shadow-md hover:shadow-lg"
|
| 133 |
+
>
|
| 134 |
+
<Play className="w-5 h-5 group-hover:scale-110 transition-transform duration-300" />
|
| 135 |
+
<span className="font-medium">Enable Live Streaming</span>
|
| 136 |
+
</button>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return (
|
| 144 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-300 hover:shadow-md hover:bg-white/80">
|
| 145 |
+
<div className="p-6">
|
| 146 |
+
<div className="flex items-center justify-between mb-6">
|
| 147 |
+
<div className="flex items-center space-x-3">
|
| 148 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-400 to-stone-500 dark:from-stone-500 dark:to-stone-600 rounded-xl flex items-center justify-center shadow-md">
|
| 149 |
+
<Monitor className="w-5 h-5 text-white" />
|
| 150 |
+
</div>
|
| 151 |
+
<div>
|
| 152 |
+
<h2 className="text-lg font-medium text-stone-800 dark:text-stone-200">Live Browser View</h2>
|
| 153 |
+
<p className="text-sm text-stone-600 dark:text-stone-400 font-light">Real-time browser streaming with interaction</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div className="flex items-center space-x-4">
|
| 158 |
+
<div className={`flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300 ${
|
| 159 |
+
isConnected
|
| 160 |
+
? 'bg-sage-100/80 dark:bg-sage-800/50 text-sage-800 dark:text-sage-200 backdrop-blur-sm'
|
| 161 |
+
: 'bg-blush-100/80 dark:bg-blush-800/50 text-blush-800 dark:text-blush-200 backdrop-blur-sm'
|
| 162 |
+
}`}>
|
| 163 |
+
<Circle className={`w-2 h-2 ${isConnected ? 'text-sage-600 dark:text-sage-400 fill-current animate-pulse' : 'text-blush-600 dark:text-blush-400 fill-current'}`} />
|
| 164 |
+
<span>{isConnected ? 'Live' : 'Offline'}</span>
|
| 165 |
+
</div>
|
| 166 |
+
<span className="text-xs text-stone-500 dark:text-stone-400 font-light">{streamStats.fps} FPS</span>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<div className="relative bg-stone-900 dark:bg-stone-950 rounded-xl overflow-hidden border border-stone-200/60 dark:border-stone-600/60 shadow-lg">
|
| 171 |
+
{currentFrame ? (
|
| 172 |
+
<img
|
| 173 |
+
src={`data:image/jpeg;base64,${currentFrame}`}
|
| 174 |
+
alt="Browser Stream"
|
| 175 |
+
className="w-full h-auto cursor-pointer hover:opacity-95 transition-opacity duration-200"
|
| 176 |
+
onClick={handleStreamClick}
|
| 177 |
+
/>
|
| 178 |
+
) : (
|
| 179 |
+
<div className="aspect-video flex items-center justify-center bg-stone-900 dark:bg-stone-950">
|
| 180 |
+
<div className="text-center animate-in fade-in duration-500">
|
| 181 |
+
<Monitor className="w-16 h-16 text-stone-400 dark:text-stone-500 mx-auto mb-4" />
|
| 182 |
+
<p className="text-stone-300 dark:text-stone-400 text-sm font-light">Waiting for stream data...</p>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{/* Stream Controls Overlay */}
|
| 188 |
+
<div className="absolute bottom-4 left-4 right-4">
|
| 189 |
+
<div className="bg-black/70 backdrop-blur-md rounded-xl p-4 border border-white/10">
|
| 190 |
+
<div className="flex items-center justify-between">
|
| 191 |
+
<div className="flex items-center space-x-3">
|
| 192 |
+
<button
|
| 193 |
+
onClick={handleConnect}
|
| 194 |
+
disabled={isConnected || !jobId}
|
| 195 |
+
className="px-4 py-2 bg-emerald-500 text-white rounded-lg text-sm hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center space-x-2 group"
|
| 196 |
+
>
|
| 197 |
+
<Play className="w-4 h-4 group-hover:scale-110 transition-transform duration-200" />
|
| 198 |
+
<span>Connect</span>
|
| 199 |
+
</button>
|
| 200 |
+
<button
|
| 201 |
+
onClick={handleDisconnect}
|
| 202 |
+
disabled={!isConnected}
|
| 203 |
+
className="px-4 py-2 bg-rose-500 text-white rounded-lg text-sm hover:bg-rose-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center space-x-2 group"
|
| 204 |
+
>
|
| 205 |
+
<Square className="w-4 h-4 group-hover:scale-110 transition-transform duration-200" />
|
| 206 |
+
<span>Disconnect</span>
|
| 207 |
+
</button>
|
| 208 |
+
<button className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-all duration-200 flex items-center space-x-2 group">
|
| 209 |
+
<Camera className="w-4 h-4 group-hover:scale-110 transition-transform duration-200" />
|
| 210 |
+
<span>Capture</span>
|
| 211 |
+
</button>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div className="flex items-center space-x-3 text-white text-sm">
|
| 215 |
+
<button className="p-2 hover:bg-white/10 rounded-lg transition-colors duration-200">
|
| 216 |
+
<Maximize2 className="w-4 h-4" />
|
| 217 |
+
</button>
|
| 218 |
+
<Settings className="w-4 h-4" />
|
| 219 |
+
<select className="bg-stone-700/80 text-white rounded-lg px-3 py-1 text-xs border-0 focus:ring-2 focus:ring-purple-500 backdrop-blur-sm">
|
| 220 |
+
<option value="60">Low Quality</option>
|
| 221 |
+
<option value="80" selected>Medium Quality</option>
|
| 222 |
+
<option value="100">High Quality</option>
|
| 223 |
+
</select>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
);
|
| 232 |
+
};
|
frontend/src/components/TokenUsage.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BarChart3, TrendingUp, Zap, Activity } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface TokenUsageProps {
|
| 5 |
+
usage: {
|
| 6 |
+
prompt_tokens: number;
|
| 7 |
+
response_tokens: number;
|
| 8 |
+
total_tokens: number;
|
| 9 |
+
api_calls: number;
|
| 10 |
+
};
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const TokenUsage: React.FC<TokenUsageProps> = ({ usage }) => {
|
| 14 |
+
return (
|
| 15 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 overflow-hidden transition-all duration-300 hover:shadow-md hover:bg-white/80">
|
| 16 |
+
<div className="p-6">
|
| 17 |
+
<div className="flex items-center space-x-3 mb-6">
|
| 18 |
+
<div className="w-10 h-10 bg-gradient-to-br from-stone-400 to-stone-500 dark:from-stone-500 dark:to-stone-600 rounded-xl flex items-center justify-center shadow-md">
|
| 19 |
+
<BarChart3 className="w-5 h-5 text-white" />
|
| 20 |
+
</div>
|
| 21 |
+
<div>
|
| 22 |
+
<h2 className="text-lg font-medium text-stone-800 dark:text-stone-200">Token Usage</h2>
|
| 23 |
+
<p className="text-sm text-stone-600 dark:text-stone-400 font-light">AI model consumption</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div className="grid grid-cols-2 gap-4">
|
| 28 |
+
<div className="bg-gradient-to-br from-stone-50/80 to-stone-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-stone-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 29 |
+
<div className="flex items-center justify-between mb-2">
|
| 30 |
+
<Activity className="w-4 h-4 text-stone-600 dark:text-stone-400" />
|
| 31 |
+
<span className="text-xs text-stone-600/70 dark:text-stone-400/70 font-medium tracking-wide">TOTAL</span>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="text-2xl font-light text-stone-700 dark:text-stone-300 mb-1">{usage.total_tokens.toLocaleString()}</div>
|
| 34 |
+
<div className="text-sm text-stone-600/80 dark:text-stone-400/80 font-light">Tokens</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="bg-gradient-to-br from-sage-50/80 to-sage-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-sage-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 38 |
+
<div className="flex items-center justify-between mb-2">
|
| 39 |
+
<TrendingUp className="w-4 h-4 text-sage-600 dark:text-sage-400" />
|
| 40 |
+
<span className="text-xs text-sage-600/70 dark:text-sage-400/70 font-medium tracking-wide">INPUT</span>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="text-2xl font-light text-sage-700 dark:text-sage-300 mb-1">{usage.prompt_tokens.toLocaleString()}</div>
|
| 43 |
+
<div className="text-sm text-sage-600/80 dark:text-sage-400/80 font-light">Prompt</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="bg-gradient-to-br from-lavender-50/80 to-lavender-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-lavender-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 47 |
+
<div className="flex items-center justify-between mb-2">
|
| 48 |
+
<Zap className="w-4 h-4 text-lavender-600 dark:text-lavender-400" />
|
| 49 |
+
<span className="text-xs text-lavender-600/70 dark:text-lavender-400/70 font-medium tracking-wide">OUTPUT</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="text-2xl font-light text-lavender-700 dark:text-lavender-300 mb-1">{usage.response_tokens.toLocaleString()}</div>
|
| 52 |
+
<div className="text-sm text-lavender-600/80 dark:text-lavender-400/80 font-light">Response</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="bg-gradient-to-br from-peach-50/80 to-peach-100/50 dark:from-stone-800/50 dark:to-stone-700/50 p-4 rounded-xl border border-peach-200/40 dark:border-stone-600/40 group hover:shadow-sm transition-all duration-300 backdrop-blur-sm">
|
| 56 |
+
<div className="flex items-center justify-between mb-2">
|
| 57 |
+
<Activity className="w-4 h-4 text-peach-600 dark:text-peach-400" />
|
| 58 |
+
<span className="text-xs text-peach-600/70 dark:text-peach-400/70 font-medium tracking-wide">CALLS</span>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="text-2xl font-light text-peach-700 dark:text-peach-300 mb-1">{usage.api_calls}</div>
|
| 61 |
+
<div className="text-sm text-peach-600/80 dark:text-peach-400/80 font-light">API Requests</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
};
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* Custom color definitions for softer palette */
|
| 6 |
+
:root {
|
| 7 |
+
--sage-50: #f6f7f6;
|
| 8 |
+
--sage-100: #e8eae8;
|
| 9 |
+
--sage-200: #d1d5d1;
|
| 10 |
+
--sage-300: #adb5ad;
|
| 11 |
+
--sage-400: #8a948a;
|
| 12 |
+
--sage-500: #6b756b;
|
| 13 |
+
--sage-600: #565e56;
|
| 14 |
+
--sage-700: #484e48;
|
| 15 |
+
--sage-800: #3d423d;
|
| 16 |
+
--sage-900: #343834;
|
| 17 |
+
|
| 18 |
+
--lavender-50: #f7f6f9;
|
| 19 |
+
--lavender-100: #ede9f2;
|
| 20 |
+
--lavender-200: #ddd6e7;
|
| 21 |
+
--lavender-300: #c4b8d4;
|
| 22 |
+
--lavender-400: #a892bd;
|
| 23 |
+
--lavender-500: #9070a6;
|
| 24 |
+
--lavender-600: #7a5a8a;
|
| 25 |
+
--lavender-700: #654a71;
|
| 26 |
+
--lavender-800: #553f5e;
|
| 27 |
+
--lavender-900: #49374f;
|
| 28 |
+
|
| 29 |
+
--peach-50: #fef7f3;
|
| 30 |
+
--peach-100: #fdede5;
|
| 31 |
+
--peach-200: #fad8ca;
|
| 32 |
+
--peach-300: #f6baa4;
|
| 33 |
+
--peach-400: #f0926d;
|
| 34 |
+
--peach-500: #e97142;
|
| 35 |
+
--peach-600: #da5a28;
|
| 36 |
+
--peach-700: #b6481e;
|
| 37 |
+
--peach-800: #923d1d;
|
| 38 |
+
--peach-900: #76361c;
|
| 39 |
+
|
| 40 |
+
--honey-50: #fefbf3;
|
| 41 |
+
--honey-100: #fdf4e1;
|
| 42 |
+
--honey-200: #fae7c2;
|
| 43 |
+
--honey-300: #f6d498;
|
| 44 |
+
--honey-400: #f1bc6c;
|
| 45 |
+
--honey-500: #eca54a;
|
| 46 |
+
--honey-600: #dd8f39;
|
| 47 |
+
--honey-700: #b87532;
|
| 48 |
+
--honey-800: #945d30;
|
| 49 |
+
--honey-900: #784d2a;
|
| 50 |
+
|
| 51 |
+
--blush-50: #fdf4f3;
|
| 52 |
+
--blush-100: #fce7e6;
|
| 53 |
+
--blush-200: #f9d4d2;
|
| 54 |
+
--blush-300: #f4b5b1;
|
| 55 |
+
--blush-400: #ec8b85;
|
| 56 |
+
--blush-500: #e0675e;
|
| 57 |
+
--blush-600: #cd4f45;
|
| 58 |
+
--blush-700: #ab3f37;
|
| 59 |
+
--blush-800: #8d3832;
|
| 60 |
+
--blush-900: #753530;
|
| 61 |
+
|
| 62 |
+
--sky-50: #f0f9ff;
|
| 63 |
+
--sky-100: #e0f2fe;
|
| 64 |
+
--sky-200: #bae6fd;
|
| 65 |
+
--sky-300: #7dd3fc;
|
| 66 |
+
--sky-400: #38bdf8;
|
| 67 |
+
--sky-500: #0ea5e9;
|
| 68 |
+
--sky-600: #0284c7;
|
| 69 |
+
--sky-700: #0369a1;
|
| 70 |
+
--sky-800: #075985;
|
| 71 |
+
--sky-900: #0c4a6e;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Apply custom colors */
|
| 75 |
+
.bg-sage-50 { background-color: var(--sage-50); }
|
| 76 |
+
.bg-sage-100 { background-color: var(--sage-100); }
|
| 77 |
+
.text-sage-600 { color: var(--sage-600); }
|
| 78 |
+
.text-sage-700 { color: var(--sage-700); }
|
| 79 |
+
.text-sage-300 { color: var(--sage-300); }
|
| 80 |
+
.text-sage-400 { color: var(--sage-400); }
|
| 81 |
+
.border-sage-200 { border-color: var(--sage-200); }
|
| 82 |
+
|
| 83 |
+
.bg-lavender-50 { background-color: var(--lavender-50); }
|
| 84 |
+
.bg-lavender-100 { background-color: var(--lavender-100); }
|
| 85 |
+
.text-lavender-600 { color: var(--lavender-600); }
|
| 86 |
+
.text-lavender-700 { color: var(--lavender-700); }
|
| 87 |
+
.text-lavender-300 { color: var(--lavender-300); }
|
| 88 |
+
.text-lavender-400 { color: var(--lavender-400); }
|
| 89 |
+
.border-lavender-200 { border-color: var(--lavender-200); }
|
| 90 |
+
|
| 91 |
+
.bg-peach-50 { background-color: var(--peach-50); }
|
| 92 |
+
.bg-peach-100 { background-color: var(--peach-100); }
|
| 93 |
+
.text-peach-600 { color: var(--peach-600); }
|
| 94 |
+
.text-peach-700 { color: var(--peach-700); }
|
| 95 |
+
.text-peach-300 { color: var(--peach-300); }
|
| 96 |
+
.text-peach-400 { color: var(--peach-400); }
|
| 97 |
+
.border-peach-200 { border-color: var(--peach-200); }
|
| 98 |
+
|
| 99 |
+
.bg-honey-50 { background-color: var(--honey-50); }
|
| 100 |
+
.bg-honey-100 { background-color: var(--honey-100); }
|
| 101 |
+
.text-honey-600 { color: var(--honey-600); }
|
| 102 |
+
.text-honey-700 { color: var(--honey-700); }
|
| 103 |
+
.text-honey-300 { color: var(--honey-300); }
|
| 104 |
+
.text-honey-400 { color: var(--honey-400); }
|
| 105 |
+
.border-honey-200 { border-color: var(--honey-200); }
|
| 106 |
+
|
| 107 |
+
.bg-blush-50 { background-color: var(--blush-50); }
|
| 108 |
+
.bg-blush-100 { background-color: var(--blush-100); }
|
| 109 |
+
.text-blush-600 { color: var(--blush-600); }
|
| 110 |
+
.text-blush-700 { color: var(--blush-700); }
|
| 111 |
+
.text-blush-300 { color: var(--blush-300); }
|
| 112 |
+
.text-blush-400 { color: var(--blush-400); }
|
| 113 |
+
.border-blush-200 { border-color: var(--blush-200); }
|
| 114 |
+
|
| 115 |
+
.bg-sky-50 { background-color: var(--sky-50); }
|
| 116 |
+
.bg-sky-100 { background-color: var(--sky-100); }
|
| 117 |
+
.text-sky-600 { color: var(--sky-600); }
|
| 118 |
+
.text-sky-700 { color: var(--sky-700); }
|
| 119 |
+
.text-sky-300 { color: var(--sky-300); }
|
| 120 |
+
.text-sky-400 { color: var(--sky-400); }
|
| 121 |
+
.border-sky-200 { border-color: var(--sky-200); }
|
| 122 |
+
|
| 123 |
+
@layer base {
|
| 124 |
+
html {
|
| 125 |
+
scroll-behavior: smooth;
|
| 126 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
body {
|
| 130 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 131 |
+
margin: 0;
|
| 132 |
+
padding: 0;
|
| 133 |
+
-webkit-font-smoothing: antialiased;
|
| 134 |
+
-moz-osx-font-smoothing: grayscale;
|
| 135 |
+
background: linear-gradient(135deg, #fafaf9 0%, #f5f5f4 50%, #fef7ed 100%);
|
| 136 |
+
transition: background 0.3s ease;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.dark body {
|
| 140 |
+
background: linear-gradient(135deg, #1c1917 0%, #292524 50%, #44403c 100%);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@layer components {
|
| 145 |
+
.animate-in {
|
| 146 |
+
animation: animate-in 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.fade-in {
|
| 150 |
+
animation: fade-in 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.slide-in-from-top-1 {
|
| 154 |
+
animation: slide-in-from-top-1 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.slide-in-from-top-2 {
|
| 158 |
+
animation: slide-in-from-top-2 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.slide-in-from-bottom-2 {
|
| 162 |
+
animation: slide-in-from-bottom-2 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.slide-in-from-bottom-4 {
|
| 166 |
+
animation: slide-in-from-bottom-4 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.slide-in-from-left-4 {
|
| 170 |
+
animation: slide-in-from-left-4 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.slide-in-from-right-4 {
|
| 174 |
+
animation: slide-in-from-right-4 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.zoom-in-95 {
|
| 178 |
+
animation: zoom-in-95 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.btn-primary {
|
| 182 |
+
@apply bg-gradient-to-r from-amber-500 to-orange-500 text-white px-8 py-4 rounded-xl font-medium hover:from-amber-600 hover:to-orange-600 focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:ring-offset-2 transition-all duration-300 shadow-md hover:shadow-lg;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.card {
|
| 186 |
+
@apply bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-stone-200/60 p-6;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.input-field {
|
| 190 |
+
@apply w-full px-4 py-3 border border-stone-300/60 rounded-xl focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition-all duration-200 bg-white/50 backdrop-blur-sm;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.status-badge {
|
| 194 |
+
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-medium backdrop-blur-sm;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@keyframes animate-in {
|
| 199 |
+
0% {
|
| 200 |
+
opacity: 0;
|
| 201 |
+
transform: translateY(-8px) scale(0.96);
|
| 202 |
+
}
|
| 203 |
+
100% {
|
| 204 |
+
opacity: 1;
|
| 205 |
+
transform: translateY(0) scale(1);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
@keyframes fade-in {
|
| 210 |
+
0% {
|
| 211 |
+
opacity: 0;
|
| 212 |
+
}
|
| 213 |
+
100% {
|
| 214 |
+
opacity: 1;
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
@keyframes slide-in-from-top-1 {
|
| 219 |
+
0% {
|
| 220 |
+
opacity: 0;
|
| 221 |
+
transform: translateY(-4px);
|
| 222 |
+
}
|
| 223 |
+
100% {
|
| 224 |
+
opacity: 1;
|
| 225 |
+
transform: translateY(0);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
@keyframes slide-in-from-top-2 {
|
| 230 |
+
0% {
|
| 231 |
+
opacity: 0;
|
| 232 |
+
transform: translateY(-8px);
|
| 233 |
+
}
|
| 234 |
+
100% {
|
| 235 |
+
opacity: 1;
|
| 236 |
+
transform: translateY(0);
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
@keyframes slide-in-from-bottom-2 {
|
| 241 |
+
0% {
|
| 242 |
+
opacity: 0;
|
| 243 |
+
transform: translateY(8px);
|
| 244 |
+
}
|
| 245 |
+
100% {
|
| 246 |
+
opacity: 1;
|
| 247 |
+
transform: translateY(0);
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
@keyframes slide-in-from-bottom-4 {
|
| 252 |
+
0% {
|
| 253 |
+
opacity: 0;
|
| 254 |
+
transform: translateY(16px);
|
| 255 |
+
}
|
| 256 |
+
100% {
|
| 257 |
+
opacity: 1;
|
| 258 |
+
transform: translateY(0);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
@keyframes slide-in-from-left-4 {
|
| 263 |
+
0% {
|
| 264 |
+
opacity: 0;
|
| 265 |
+
transform: translateX(-16px);
|
| 266 |
+
}
|
| 267 |
+
100% {
|
| 268 |
+
opacity: 1;
|
| 269 |
+
transform: translateX(0);
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
@keyframes slide-in-from-right-4 {
|
| 274 |
+
0% {
|
| 275 |
+
opacity: 0;
|
| 276 |
+
transform: translateX(16px);
|
| 277 |
+
}
|
| 278 |
+
100% {
|
| 279 |
+
opacity: 1;
|
| 280 |
+
transform: translateX(0);
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
@keyframes zoom-in-95 {
|
| 285 |
+
0% {
|
| 286 |
+
opacity: 0;
|
| 287 |
+
transform: scale(0.95);
|
| 288 |
+
}
|
| 289 |
+
100% {
|
| 290 |
+
opacity: 1;
|
| 291 |
+
transform: scale(1);
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/* Scrollbar styling for webkit browsers */
|
| 296 |
+
::-webkit-scrollbar {
|
| 297 |
+
width: 8px;
|
| 298 |
+
height: 8px;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
::-webkit-scrollbar-track {
|
| 302 |
+
background: #f8fafc;
|
| 303 |
+
border-radius: 4px;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
::-webkit-scrollbar-thumb {
|
| 307 |
+
background: linear-gradient(135deg, #d6d3d1, #a8a29e);
|
| 308 |
+
border-radius: 4px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
::-webkit-scrollbar-thumb:hover {
|
| 312 |
+
background: linear-gradient(135deg, #a8a29e, #78716c);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* Custom focus styles */
|
| 316 |
+
.focus\:ring-amber-500\/20:focus {
|
| 317 |
+
--tw-ring-color: rgb(245 158 11 / 0.2);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/* Gradient backgrounds */
|
| 321 |
+
.gradient-warm {
|
| 322 |
+
background: linear-gradient(135deg, #fef7ed 0%, #fed7aa 50%, #fdba74 100%);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.glass-effect {
|
| 326 |
+
@apply bg-white/70 backdrop-blur-md border border-stone-200/60;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/* Hover effects */
|
| 330 |
+
.hover-lift {
|
| 331 |
+
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.hover-lift:hover {
|
| 335 |
+
transform: translateY(-2px) scale(1.02);
|
| 336 |
+
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -3px rgba(0, 0, 0, 0.05);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/* Loading animations */
|
| 340 |
+
@keyframes pulse-soft {
|
| 341 |
+
0%, 100% {
|
| 342 |
+
opacity: 1;
|
| 343 |
+
}
|
| 344 |
+
50% {
|
| 345 |
+
opacity: 0.7;
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.animate-pulse-soft {
|
| 350 |
+
animation: pulse-soft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* Interactive elements */
|
| 354 |
+
.interactive-scale {
|
| 355 |
+
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.interactive-scale:hover {
|
| 359 |
+
transform: scale(1.05);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.interactive-scale:active {
|
| 363 |
+
transform: scale(0.98);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* Floating elements */
|
| 367 |
+
@keyframes float {
|
| 368 |
+
0%, 100% {
|
| 369 |
+
transform: translateY(0px);
|
| 370 |
+
}
|
| 371 |
+
50% {
|
| 372 |
+
transform: translateY(-4px);
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.animate-float {
|
| 377 |
+
animation: float 3s ease-in-out infinite;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/* Staggered animations */
|
| 381 |
+
.stagger-1 { animation-delay: 0.1s; }
|
| 382 |
+
.stagger-2 { animation-delay: 0.2s; }
|
| 383 |
+
.stagger-3 { animation-delay: 0.3s; }
|
| 384 |
+
.stagger-4 { animation-delay: 0.4s; }
|
| 385 |
+
.stagger-5 { animation-delay: 0.5s; }
|
| 386 |
+
.stagger-6 { animation-delay: 0.6s; }
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react';
|
| 2 |
+
import { createRoot } from 'react-dom/client';
|
| 3 |
+
import App from './App.tsx';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>
|
| 10 |
+
);
|
frontend/src/services/WebSocketManager.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type EventCallback = (data: any) => void;
|
| 2 |
+
|
| 3 |
+
export class WebSocketManager {
|
| 4 |
+
private websocket: WebSocket | null = null;
|
| 5 |
+
private streamWebSocket: WebSocket | null = null;
|
| 6 |
+
private eventListeners: Map<string, EventCallback[]> = new Map();
|
| 7 |
+
private reconnectAttempts = 0;
|
| 8 |
+
private maxReconnectAttempts = 5;
|
| 9 |
+
private reconnectDelay = 1000;
|
| 10 |
+
|
| 11 |
+
public connect(jobId: string): void {
|
| 12 |
+
if (this.websocket) {
|
| 13 |
+
this.websocket.close();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Detect if we're in development mode (frontend on different port than backend)
|
| 17 |
+
const isDev = window.location.port === '5173';
|
| 18 |
+
const backendHost = isDev ? 'localhost:8000' : window.location.host;
|
| 19 |
+
const wsBase = `${window.location.protocol === "https:" ? "wss" : "ws"}://${backendHost}`;
|
| 20 |
+
console.log(`📡 Connecting to WebSocket: ${wsBase}/ws/${jobId}`);
|
| 21 |
+
this.websocket = new WebSocket(`${wsBase}/ws/${jobId}`);
|
| 22 |
+
|
| 23 |
+
this.websocket.onopen = () => {
|
| 24 |
+
console.log('📡 WebSocket connected successfully');
|
| 25 |
+
this.reconnectAttempts = 0;
|
| 26 |
+
this.emit('connected', { status: 'connected' });
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
this.websocket.onmessage = (event: MessageEvent) => {
|
| 30 |
+
try {
|
| 31 |
+
const data = JSON.parse(event.data);
|
| 32 |
+
console.log('📨 WebSocket message received:', data.type, data);
|
| 33 |
+
|
| 34 |
+
// Handle different message types
|
| 35 |
+
switch (data.type) {
|
| 36 |
+
case 'proxy_stats':
|
| 37 |
+
this.emit('proxy_stats', data.stats || data);
|
| 38 |
+
break;
|
| 39 |
+
case 'decision':
|
| 40 |
+
this.emit('decision', data.decision || data);
|
| 41 |
+
break;
|
| 42 |
+
case 'screenshot':
|
| 43 |
+
this.emit('screenshot', data.screenshot || data);
|
| 44 |
+
break;
|
| 45 |
+
case 'token_usage':
|
| 46 |
+
this.emit('token_usage', data.token_usage || data);
|
| 47 |
+
break;
|
| 48 |
+
case 'page_info':
|
| 49 |
+
this.emit('page_info', data);
|
| 50 |
+
break;
|
| 51 |
+
case 'extraction':
|
| 52 |
+
this.emit('extraction', data);
|
| 53 |
+
break;
|
| 54 |
+
case 'streaming_info':
|
| 55 |
+
this.emit('streaming_info', data);
|
| 56 |
+
break;
|
| 57 |
+
case 'error':
|
| 58 |
+
this.emit('error', data);
|
| 59 |
+
break;
|
| 60 |
+
default:
|
| 61 |
+
if (data.status) {
|
| 62 |
+
this.emit('status', data);
|
| 63 |
+
} else {
|
| 64 |
+
this.emit(data.type, data);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error('Error parsing WebSocket message:', error, event.data);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
this.websocket.onclose = (event: CloseEvent) => {
|
| 73 |
+
console.log('📡 WebSocket disconnected:', event.code, event.reason);
|
| 74 |
+
this.websocket = null;
|
| 75 |
+
|
| 76 |
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
| 77 |
+
this.reconnectAttempts++;
|
| 78 |
+
console.log(`🔄 Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
|
| 79 |
+
setTimeout(() => this.connect(jobId), this.reconnectDelay);
|
| 80 |
+
this.reconnectDelay *= 2;
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
this.websocket.onerror = (error: Event) => {
|
| 85 |
+
console.error('📡 WebSocket error:', error);
|
| 86 |
+
this.emit('error', { error: 'WebSocket connection error' });
|
| 87 |
+
};
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
public connectStream(jobId: string): void {
|
| 91 |
+
if (this.streamWebSocket) {
|
| 92 |
+
this.streamWebSocket.close();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Detect if we're in development mode (frontend on different port than backend)
|
| 96 |
+
const isDev = window.location.port === '5173';
|
| 97 |
+
const backendHost = isDev ? 'localhost:8000' : window.location.host;
|
| 98 |
+
const wsBase = `${window.location.protocol === "https:" ? "wss" : "ws"}://${backendHost}`;
|
| 99 |
+
console.log(`🎥 Connecting to Stream WebSocket: ${wsBase}/stream/${jobId}`);
|
| 100 |
+
this.streamWebSocket = new WebSocket(`${wsBase}/stream/${jobId}`);
|
| 101 |
+
|
| 102 |
+
this.streamWebSocket.onopen = () => {
|
| 103 |
+
console.log('🎥 Stream WebSocket connected successfully');
|
| 104 |
+
this.emit('stream_connected', { status: 'connected' });
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
this.streamWebSocket.onmessage = (event: MessageEvent) => {
|
| 108 |
+
try {
|
| 109 |
+
const data = JSON.parse(event.data);
|
| 110 |
+
console.log('🎥 Stream message received:', data.type);
|
| 111 |
+
this.emit('stream_' + data.type, data);
|
| 112 |
+
} catch (error) {
|
| 113 |
+
console.error('Error parsing stream message:', error);
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
this.streamWebSocket.onclose = () => {
|
| 118 |
+
console.log('🎥 Stream WebSocket disconnected');
|
| 119 |
+
this.streamWebSocket = null;
|
| 120 |
+
this.emit('stream_disconnected', { status: 'disconnected' });
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
this.streamWebSocket.onerror = (error: Event) => {
|
| 124 |
+
console.error('🎥 Stream WebSocket error:', error);
|
| 125 |
+
this.emit('stream_error', { error: 'Stream connection error' });
|
| 126 |
+
};
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
public sendStreamMessage(message: any): void {
|
| 130 |
+
if (this.streamWebSocket && this.streamWebSocket.readyState === WebSocket.OPEN) {
|
| 131 |
+
this.streamWebSocket.send(JSON.stringify(message));
|
| 132 |
+
} else {
|
| 133 |
+
console.warn('Stream WebSocket not connected');
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
public on(event: string, callback: EventCallback): void {
|
| 138 |
+
if (!this.eventListeners.has(event)) {
|
| 139 |
+
this.eventListeners.set(event, []);
|
| 140 |
+
}
|
| 141 |
+
this.eventListeners.get(event)?.push(callback);
|
| 142 |
+
console.log(`📝 Event listener added for: ${event}`);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
private emit(event: string, data: any): void {
|
| 146 |
+
const listeners = this.eventListeners.get(event) || [];
|
| 147 |
+
console.log(`📤 Emitting event: ${event} to ${listeners.length} listeners`);
|
| 148 |
+
listeners.forEach(callback => {
|
| 149 |
+
try {
|
| 150 |
+
callback(data);
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error(`Error in event listener for ${event}:`, error);
|
| 153 |
+
}
|
| 154 |
+
});
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
public disconnect(): void {
|
| 158 |
+
if (this.websocket) {
|
| 159 |
+
this.websocket.close();
|
| 160 |
+
this.websocket = null;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
public disconnectStream(): void {
|
| 165 |
+
if (this.streamWebSocket) {
|
| 166 |
+
this.streamWebSocket.close();
|
| 167 |
+
this.streamWebSocket = null;
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
public isConnected(): boolean {
|
| 172 |
+
return this.websocket !== null && this.websocket.readyState === WebSocket.OPEN;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
public isStreamConnected(): boolean {
|
| 176 |
+
return this.streamWebSocket !== null && this.streamWebSocket.readyState === WebSocket.OPEN;
|
| 177 |
+
}
|
| 178 |
+
}
|
frontend/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
| 4 |
+
darkMode: 'class',
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
sage: {
|
| 9 |
+
50: '#f6f7f6',
|
| 10 |
+
100: '#e8eae8',
|
| 11 |
+
200: '#d1d5d1',
|
| 12 |
+
300: '#adb5ad',
|
| 13 |
+
400: '#8a948a',
|
| 14 |
+
500: '#6b756b',
|
| 15 |
+
600: '#565e56',
|
| 16 |
+
700: '#484e48',
|
| 17 |
+
800: '#3d423d',
|
| 18 |
+
900: '#343834',
|
| 19 |
+
},
|
| 20 |
+
lavender: {
|
| 21 |
+
50: '#f7f6f9',
|
| 22 |
+
100: '#ede9f2',
|
| 23 |
+
200: '#ddd6e7',
|
| 24 |
+
300: '#c4b8d4',
|
| 25 |
+
400: '#a892bd',
|
| 26 |
+
500: '#9070a6',
|
| 27 |
+
600: '#7a5a8a',
|
| 28 |
+
700: '#654a71',
|
| 29 |
+
800: '#553f5e',
|
| 30 |
+
900: '#49374f',
|
| 31 |
+
},
|
| 32 |
+
peach: {
|
| 33 |
+
50: '#fef7f3',
|
| 34 |
+
100: '#fdede5',
|
| 35 |
+
200: '#fad8ca',
|
| 36 |
+
300: '#f6baa4',
|
| 37 |
+
400: '#f0926d',
|
| 38 |
+
500: '#e97142',
|
| 39 |
+
600: '#da5a28',
|
| 40 |
+
700: '#b6481e',
|
| 41 |
+
800: '#923d1d',
|
| 42 |
+
900: '#76361c',
|
| 43 |
+
},
|
| 44 |
+
honey: {
|
| 45 |
+
50: '#fefbf3',
|
| 46 |
+
100: '#fdf4e1',
|
| 47 |
+
200: '#fae7c2',
|
| 48 |
+
300: '#f6d498',
|
| 49 |
+
400: '#f1bc6c',
|
| 50 |
+
500: '#eca54a',
|
| 51 |
+
600: '#dd8f39',
|
| 52 |
+
700: '#b87532',
|
| 53 |
+
800: '#945d30',
|
| 54 |
+
900: '#784d2a',
|
| 55 |
+
},
|
| 56 |
+
blush: {
|
| 57 |
+
50: '#fdf4f3',
|
| 58 |
+
100: '#fce7e6',
|
| 59 |
+
200: '#f9d4d2',
|
| 60 |
+
300: '#f4b5b1',
|
| 61 |
+
400: '#ec8b85',
|
| 62 |
+
500: '#e0675e',
|
| 63 |
+
600: '#cd4f45',
|
| 64 |
+
700: '#ab3f37',
|
| 65 |
+
800: '#8d3832',
|
| 66 |
+
900: '#753530',
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
plugins: [],
|
| 72 |
+
};
|
frontend/tsconfig.app.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
|
| 9 |
+
/* Bundler mode */
|
| 10 |
+
"moduleResolution": "bundler",
|
| 11 |
+
"allowImportingTsExtensions": true,
|
| 12 |
+
"isolatedModules": true,
|
| 13 |
+
"moduleDetection": "force",
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
"jsx": "react-jsx",
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"noFallthroughCasesInSwitch": true
|
| 22 |
+
},
|
| 23 |
+
"include": ["src"]
|
| 24 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["ES2023"],
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
|
| 8 |
+
/* Bundler mode */
|
| 9 |
+
"moduleResolution": "bundler",
|
| 10 |
+
"allowImportingTsExtensions": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"moduleDetection": "force",
|
| 13 |
+
"noEmit": true,
|
| 14 |
+
|
| 15 |
+
/* Linting */
|
| 16 |
+
"strict": true,
|
| 17 |
+
"noUnusedLocals": true,
|
| 18 |
+
"noUnusedParameters": true,
|
| 19 |
+
"noFallthroughCasesInSwitch": true
|
| 20 |
+
},
|
| 21 |
+
"include": ["vite.config.ts"]
|
| 22 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
// https://vitejs.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
optimizeDeps: {
|
| 8 |
+
exclude: ['lucide-react'],
|
| 9 |
+
},
|
| 10 |
+
});
|
render.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: browserpilot
|
| 4 |
+
env: docker
|
| 5 |
+
region: oregon
|
| 6 |
+
plan: starter
|
| 7 |
+
dockerfilePath: ./Dockerfile
|
| 8 |
+
dockerContext: .
|
| 9 |
+
healthCheckPath: /
|
| 10 |
+
envVars:
|
| 11 |
+
- key: GOOGLE_API_KEY
|
| 12 |
+
sync: false
|
| 13 |
+
- key: DATABASE_URL
|
| 14 |
+
sync: false
|
| 15 |
+
- key: SCRAPER_PROXIES
|
| 16 |
+
sync: false
|
| 17 |
+
default: []
|
| 18 |
+
- key: PYTHONUNBUFFERED
|
| 19 |
+
sync: false
|
| 20 |
+
default: 1
|
| 21 |
+
disk:
|
| 22 |
+
name: outputs
|
| 23 |
+
mountPath: /app/outputs
|
| 24 |
+
sizeGB: 5
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
playwright==1.53.0
|
| 4 |
+
google-generativeai==0.5.0
|
| 5 |
+
pydantic==2.7.1
|
| 6 |
+
bs4==0.0.2
|
| 7 |
+
lxml==5.2.1
|
| 8 |
+
markdownify==0.11.6
|
| 9 |
+
numpy==2.2.6
|
| 10 |
+
pandas==2.2.3
|
| 11 |
+
python-dateutil==2.9.0.post0
|
| 12 |
+
pytz==2025.2
|
| 13 |
+
tzdata==2025.2
|
| 14 |
+
reportlab==4.4.2
|
| 15 |
+
psycopg2-binary==2.9.9
|
| 16 |
+
asyncpg==0.29.0
|
| 17 |
+
python-telegram-bot==21.0
|
| 18 |
+
aiohttp==3.9.5
|