Spaces:
Sleeping
Sleeping
whung99 Claude Opus 4.6 commited on
Commit ·
0d37119
1
Parent(s): c859192
feat: deploy Oppy with Google API integration
Browse filesFull-stack AI assistant with Gemini, Google Calendar, Gmail, Docs
OAuth 2.0 integration, voice-first UX, and real-time SSE streaming.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .gitignore +10 -0
- .replit +10 -0
- README.md +122 -10
- backend/agent.py +388 -0
- backend/auth_cli.py +60 -0
- backend/config.json +29 -0
- backend/google_auth.py +104 -0
- backend/google_services.py +292 -0
- backend/initiative.py +50 -0
- backend/main.py +212 -0
- backend/mock_data.py +120 -0
- backend/requirements.txt +9 -0
- backend/test_smoke.py +55 -0
- backend/tools.py +126 -0
- backend/tts.py +63 -0
- backend/urgency.py +25 -0
- docs/superpowers/plans/2026-03-14-operator-plan.md +1522 -0
- docs/superpowers/specs/2026-03-14-operator-design.md +255 -0
- frontend/index.html +12 -0
- frontend/package-lock.json +1677 -0
- frontend/package.json +19 -0
- frontend/public/avatar.png +0 -0
- frontend/src/App.jsx +334 -0
- frontend/src/components/BriefPanel.jsx +147 -0
- frontend/src/components/ConversationOverlay.jsx +65 -0
- frontend/src/components/Header.jsx +125 -0
- frontend/src/components/JarvisOrb.jsx +110 -0
- frontend/src/components/OppyFace.jsx +162 -0
- frontend/src/components/ProjectCard.jsx +229 -0
- frontend/src/components/ProjectCardsDrawer.jsx +66 -0
- frontend/src/components/ToolFeed.jsx +86 -0
- frontend/src/components/VoiceControl.jsx +350 -0
- frontend/src/hooks/useGoogleAuth.js +46 -0
- frontend/src/hooks/useOperatorSSE.js +95 -0
- frontend/src/hooks/useSpeechRecognition.js +121 -0
- frontend/src/hooks/useTTS.js +56 -0
- frontend/src/hooks/useWakeWord.js +109 -0
- frontend/src/index.css +128 -0
- frontend/src/main.jsx +10 -0
- frontend/vite.config.js +10 -0
- replit.nix +7 -0
- start.sh +16 -0
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
node_modules/
|
| 5 |
+
.venv/
|
| 6 |
+
.claude/
|
| 7 |
+
frontend/dist/
|
| 8 |
+
backend/client_secret.json
|
| 9 |
+
backend/token.json
|
| 10 |
+
backend/static/
|
.replit
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
run = "bash start.sh"
|
| 2 |
+
|
| 3 |
+
[nix]
|
| 4 |
+
channel = "stable-24_05"
|
| 5 |
+
|
| 6 |
+
[env]
|
| 7 |
+
PYTHONPATH = "backend"
|
| 8 |
+
|
| 9 |
+
[deployment]
|
| 10 |
+
run = "bash start.sh"
|
README.md
CHANGED
|
@@ -1,10 +1,122 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Oppy — AI Voice Assistant for Multi-Project Management
|
| 2 |
+
|
| 3 |
+
Oppy is a voice-first AI assistant built on Google's Gemini ecosystem. It helps students and professionals juggling multiple projects (school, work, startup) stay on top of everything through natural voice conversation.
|
| 4 |
+
|
| 5 |
+
## What it does
|
| 6 |
+
|
| 7 |
+
Oppy connects to your Gmail and Google Calendar, analyzes urgency across all your projects, and talks to you like a direct, caring mentor. Ask it about your schedule, your emails, or what to prioritize — it answers with actionable advice.
|
| 8 |
+
|
| 9 |
+
**Key features:**
|
| 10 |
+
- Voice conversation powered by Gemini 3 Flash (chat) and Gemini 2.5 Flash TTS (speech)
|
| 11 |
+
- Wake word activation — say "Oppy" to start hands-free
|
| 12 |
+
- Animated SVG avatar with eye tracking, blinking, and mouth animation
|
| 13 |
+
- Real Gmail and Google Calendar integration
|
| 14 |
+
- Expandable project cards with direct action links
|
| 15 |
+
- Gemini-inspired UI (Google Sans, white theme, Google color palette)
|
| 16 |
+
|
| 17 |
+
## Tech stack
|
| 18 |
+
|
| 19 |
+
| Layer | Tech |
|
| 20 |
+
|-------|------|
|
| 21 |
+
| Backend | Python, FastAPI, SSE streaming |
|
| 22 |
+
| AI | Gemini 3 Flash (chat + function calling), Gemini 2.5 Flash Preview TTS |
|
| 23 |
+
| Frontend | React 18, Vite, Web Speech API |
|
| 24 |
+
| APIs | Gmail API, Google Calendar API |
|
| 25 |
+
|
| 26 |
+
## Quick start
|
| 27 |
+
|
| 28 |
+
### Prerequisites
|
| 29 |
+
- Python 3.11+
|
| 30 |
+
- Node.js 18+
|
| 31 |
+
- A [Google AI API key](https://aistudio.google.com/apikey)
|
| 32 |
+
|
| 33 |
+
### Setup
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
# Clone
|
| 37 |
+
git clone https://github.com/solanathouu/hack-google.git
|
| 38 |
+
cd hack-google
|
| 39 |
+
|
| 40 |
+
# Backend
|
| 41 |
+
cd backend
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
echo "GOOGLE_API_KEY=your_key_here" > .env
|
| 44 |
+
|
| 45 |
+
# Frontend
|
| 46 |
+
cd ../frontend
|
| 47 |
+
npm install
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### Run (development)
|
| 51 |
+
|
| 52 |
+
Terminal 1 — backend:
|
| 53 |
+
```bash
|
| 54 |
+
cd backend
|
| 55 |
+
uvicorn main:app --host 0.0.0.0 --port 8000
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Terminal 2 — frontend:
|
| 59 |
+
```bash
|
| 60 |
+
cd frontend
|
| 61 |
+
npx vite --host 0.0.0.0 --port 5173
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Open http://localhost:5173
|
| 65 |
+
|
| 66 |
+
### Run (production / Replit)
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
bash start.sh
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
This builds the frontend and serves everything from FastAPI on port 8000.
|
| 73 |
+
|
| 74 |
+
## How to use
|
| 75 |
+
|
| 76 |
+
1. Open the app — Oppy's avatar appears
|
| 77 |
+
2. Click the orb to authorize your microphone
|
| 78 |
+
3. Once the green "Micro actif" dot appears, say **"Oppy"** or click again
|
| 79 |
+
4. Ask anything: "What's my schedule today?", "Tell me about Sophie's email", "What should I prioritize?"
|
| 80 |
+
5. Oppy answers vocally and listens for your next question
|
| 81 |
+
6. Say "merci" or "au revoir" to end the conversation
|
| 82 |
+
|
| 83 |
+
## Project structure
|
| 84 |
+
|
| 85 |
+
```
|
| 86 |
+
backend/
|
| 87 |
+
main.py — FastAPI app, API endpoints, static file serving
|
| 88 |
+
agent.py — Gemini agent loop, chat session, system prompts
|
| 89 |
+
tts.py — Gemini TTS audio generation
|
| 90 |
+
mock_data.py — Demo emails, events, web signals
|
| 91 |
+
urgency.py — Keyword-based urgency scoring
|
| 92 |
+
initiative.py — Project health evaluation
|
| 93 |
+
tools.py — Tool functions (read_emails, get_events, search_web)
|
| 94 |
+
config.json — Project definitions
|
| 95 |
+
|
| 96 |
+
frontend/src/
|
| 97 |
+
App.jsx — Main orchestrator (phase state machine)
|
| 98 |
+
components/
|
| 99 |
+
JarvisOrb.jsx — Animated orb with phase-based effects
|
| 100 |
+
OppyFace.jsx — SVG face (sparkle eyes + mouth)
|
| 101 |
+
ConversationOverlay.jsx — Chat transcript display
|
| 102 |
+
ProjectCard.jsx — Expandable project card with sources
|
| 103 |
+
ProjectCardsDrawer.jsx — Bottom drawer for project cards
|
| 104 |
+
hooks/
|
| 105 |
+
useOperatorSSE.js — Chat API communication
|
| 106 |
+
useTTS.js — Gemini TTS playback
|
| 107 |
+
useWakeWord.js — Wake word detection
|
| 108 |
+
useSpeechRecognition.js — Speech-to-text for conversation
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## API endpoints
|
| 112 |
+
|
| 113 |
+
| Method | Path | Description |
|
| 114 |
+
|--------|------|-------------|
|
| 115 |
+
| POST | `/api/chat` | Send a message, get SSE streamed response |
|
| 116 |
+
| POST | `/api/tts` | Convert text to speech (WAV audio) |
|
| 117 |
+
| GET | `/api/project/{id}/sources` | Get emails, events, signals for a project |
|
| 118 |
+
| GET | `/api/health` | Health check |
|
| 119 |
+
|
| 120 |
+
## License
|
| 121 |
+
|
| 122 |
+
MIT
|
backend/agent.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
from typing import Callable
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from google import genai
|
| 9 |
+
from google.genai import types
|
| 10 |
+
|
| 11 |
+
from google_auth import is_authenticated
|
| 12 |
+
from google_services import fetch_emails, fetch_events
|
| 13 |
+
from initiative import evaluate_project
|
| 14 |
+
from mock_data import MOCK_EMAILS, MOCK_EVENTS
|
| 15 |
+
from tools import get_events, read_emails, search_web, read_doc, list_docs
|
| 16 |
+
from urgency import score_emails, score_urgency
|
| 17 |
+
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
SYSTEM_PROMPT = """Tu es Oppy — l'assistant personnel d'un etudiant en alternance qui jongle entre ses cours, son entreprise et sa startup.
|
| 21 |
+
|
| 22 |
+
Tu parles comme un vrai chef de projet bienveillant mais direct. Pas de blabla corporate. Tu connais la personne, tu sais qu'elle est debordee. Tu lui parles comme un mentor qui a regarde ses mails et son agenda a sa place.
|
| 23 |
+
|
| 24 |
+
Ton role : scanner ses 3 projets, detecter les urgences, et lui faire un brief cash et actionnable. Tu parles en premier. Tu ne demandes rien. Tu annonces la situation.
|
| 25 |
+
|
| 26 |
+
Style :
|
| 27 |
+
- Parle en francais naturel, comme a l'oral. Tutoie la personne.
|
| 28 |
+
- Sois direct et concret : pas de "il serait judicieux de", mais "fonce sur ca maintenant".
|
| 29 |
+
- Nomme les personnes par leur prenom (Sophie, pas "sophie.renard@bnpparibas.com").
|
| 30 |
+
- Donne des actions claires : "reponds a Sophie", "bloque 2h ce soir pour le TP".
|
| 31 |
+
- Mets de l'emotion quand c'est urgent : "la ca craint", "t'es dans le rouge".
|
| 32 |
+
|
| 33 |
+
Format OBLIGATOIRE :
|
| 34 |
+
- Ecris en paragraphes fluides, comme si tu parlais a quelqu'un. PAS de listes a puces, PAS de tirets, PAS de bullet points.
|
| 35 |
+
- Utilise des phrases completes et enchaine les idees naturellement.
|
| 36 |
+
- Separe chaque projet par son nom en gras sur une ligne, puis un paragraphe de 3-5 phrases qui resume la situation et dit quoi faire.
|
| 37 |
+
- Le ton doit etre celui d'un pote qui te brief en 2 minutes au telephone.
|
| 38 |
+
|
| 39 |
+
Exemple de format attendu :
|
| 40 |
+
|
| 41 |
+
**Alternance BNP — URGENT**
|
| 42 |
+
|
| 43 |
+
La ca craint. Sophie t'a ecrit il y a 6 jours et tu n'as toujours pas repondu, elle va finir par escalader. En plus les KPIs sont casses en prod et le filtre date deconne, le PO attend que ce soit fixe avant lundi matin sinon la sprint review de mardi sera un desastre. Reponds-lui ce soir, meme un message court pour dire que tu es dessus, et bloque ton samedi pour debugger le dashboard.
|
| 44 |
+
|
| 45 |
+
Commence par le projet le plus urgent. Termine par : "Par quoi tu veux commencer ?"
|
| 46 |
+
|
| 47 |
+
Interdictions :
|
| 48 |
+
- JAMAIS de tirets, puces, bullet points, listes numerotees. Uniquement des phrases et paragraphes.
|
| 49 |
+
- Jamais de jargon IA ou technique inutile.
|
| 50 |
+
- Jamais de "je vais analyser" ou "voici mon analyse" — tu fais, tu ne commentes pas.
|
| 51 |
+
- Ne repete jamais le contenu brut des emails. Synthetise.
|
| 52 |
+
- Ne dis jamais "N/A" ou "aucune donnee". Si tu n'as pas l'info, n'en parle pas.
|
| 53 |
+
|
| 54 |
+
Outils supplementaires :
|
| 55 |
+
- Tu peux lire un Google Doc avec read_doc(doc_id) si un lien ou ID de doc est mentionne.
|
| 56 |
+
- Tu peux lister les Google Docs recents avec list_docs().
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
TOOL_FUNCTIONS = {
|
| 60 |
+
"read_emails": read_emails,
|
| 61 |
+
"get_events": get_events,
|
| 62 |
+
"search_web": search_web,
|
| 63 |
+
"read_doc": read_doc,
|
| 64 |
+
"list_docs": list_docs,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
TOOL_DECLARATIONS = [
|
| 68 |
+
types.Tool(function_declarations=[
|
| 69 |
+
types.FunctionDeclaration(
|
| 70 |
+
name="read_emails",
|
| 71 |
+
description="Lit les emails recents pour un projet. Retourne expediteur, sujet, corps, jours depuis derniere reponse.",
|
| 72 |
+
parameters=types.Schema(
|
| 73 |
+
type="OBJECT",
|
| 74 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 75 |
+
required=["project_id"],
|
| 76 |
+
),
|
| 77 |
+
),
|
| 78 |
+
types.FunctionDeclaration(
|
| 79 |
+
name="get_events",
|
| 80 |
+
description="Recupere les evenements calendrier a venir pour un projet. Indique titre, heure, et si un bloc de preparation existe.",
|
| 81 |
+
parameters=types.Schema(
|
| 82 |
+
type="OBJECT",
|
| 83 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 84 |
+
required=["project_id"],
|
| 85 |
+
),
|
| 86 |
+
),
|
| 87 |
+
types.FunctionDeclaration(
|
| 88 |
+
name="search_web",
|
| 89 |
+
description="Recherche des signaux externes pertinents pour un projet (news, funding, annonces).",
|
| 90 |
+
parameters=types.Schema(
|
| 91 |
+
type="OBJECT",
|
| 92 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 93 |
+
required=["project_id"],
|
| 94 |
+
),
|
| 95 |
+
),
|
| 96 |
+
types.FunctionDeclaration(
|
| 97 |
+
name="read_doc",
|
| 98 |
+
description="Lit le contenu d'un Google Doc par son ID. Retourne le titre et le texte.",
|
| 99 |
+
parameters=types.Schema(
|
| 100 |
+
type="OBJECT",
|
| 101 |
+
properties={"doc_id": types.Schema(type="STRING", description="L'ID du document Google Docs (depuis l'URL)")},
|
| 102 |
+
required=["doc_id"],
|
| 103 |
+
),
|
| 104 |
+
),
|
| 105 |
+
types.FunctionDeclaration(
|
| 106 |
+
name="list_docs",
|
| 107 |
+
description="Liste les Google Docs recemment modifies. Retourne titre, ID et date de modification.",
|
| 108 |
+
parameters=types.Schema(
|
| 109 |
+
type="OBJECT",
|
| 110 |
+
properties={},
|
| 111 |
+
),
|
| 112 |
+
),
|
| 113 |
+
])
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
MAX_ITERATIONS = 15
|
| 117 |
+
TIMEOUT_SECONDS = 30
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _get_emails_for_scoring(project_id: str, keywords: list[str] | None = None) -> list[dict]:
|
| 121 |
+
"""Get emails for the deterministic scoring phase.
|
| 122 |
+
|
| 123 |
+
Uses real Gmail data if authenticated, mock data otherwise.
|
| 124 |
+
"""
|
| 125 |
+
if is_authenticated():
|
| 126 |
+
return fetch_emails(project_id, keywords=keywords)
|
| 127 |
+
return [dict(e) for e in MOCK_EMAILS.get(project_id, [])]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _get_events_for_scoring(project_id: str, keywords: list[str] | None = None) -> list[dict]:
|
| 131 |
+
"""Get events for the deterministic scoring phase.
|
| 132 |
+
|
| 133 |
+
Uses real Calendar data if authenticated, mock data otherwise.
|
| 134 |
+
"""
|
| 135 |
+
if is_authenticated():
|
| 136 |
+
return fetch_events(project_id, keywords=keywords)
|
| 137 |
+
return MOCK_EVENTS.get(project_id, [])
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def build_operator_context(projects: list[dict]) -> str:
|
| 141 |
+
"""Build a summary of projects for Gemini context."""
|
| 142 |
+
return json.dumps(
|
| 143 |
+
[{"id": p["id"], "name": p["name"], "contact": p["contact"], "deadline": p["deadline"]}
|
| 144 |
+
for p in projects],
|
| 145 |
+
ensure_ascii=False,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def build_data_context(projects: list[dict]) -> str:
|
| 150 |
+
"""Build a full data dump of emails, events and search results for chat context."""
|
| 151 |
+
lines = []
|
| 152 |
+
for p in projects:
|
| 153 |
+
pid = p["id"]
|
| 154 |
+
lines.append(f"\n=== {p['name']} (id: {pid}) ===")
|
| 155 |
+
|
| 156 |
+
if is_authenticated():
|
| 157 |
+
emails = fetch_emails(pid, keywords=p.get("keywords", []))
|
| 158 |
+
else:
|
| 159 |
+
emails = MOCK_EMAILS.get(pid, [])
|
| 160 |
+
|
| 161 |
+
if emails:
|
| 162 |
+
lines.append("Emails:")
|
| 163 |
+
for e in emails:
|
| 164 |
+
silence = f"{e['days_since_reply']}j sans reponse" if e.get("days_since_reply") is not None else "newsletter"
|
| 165 |
+
lines.append(f" - De: {e['from']} | Sujet: {e['subject']} | {silence}")
|
| 166 |
+
lines.append(f" {e.get('body', '')}")
|
| 167 |
+
|
| 168 |
+
if is_authenticated():
|
| 169 |
+
events = fetch_events(pid, keywords=p.get("keywords", []))
|
| 170 |
+
else:
|
| 171 |
+
events = MOCK_EVENTS.get(pid, [])
|
| 172 |
+
|
| 173 |
+
if events:
|
| 174 |
+
lines.append("Calendrier:")
|
| 175 |
+
for ev in events:
|
| 176 |
+
prep = "oui" if ev.get("prep_block") else "non"
|
| 177 |
+
lines.append(f" - {ev['title']} a {ev['time']} (bloc prep: {prep})")
|
| 178 |
+
return "\n".join(lines)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def create_chat_session(projects: list[dict]):
|
| 182 |
+
"""Create a persistent Gemini chat session with full project context.
|
| 183 |
+
|
| 184 |
+
Returns (client, chat) — caller must keep client alive.
|
| 185 |
+
"""
|
| 186 |
+
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
| 187 |
+
|
| 188 |
+
data_context = build_data_context(projects)
|
| 189 |
+
project_summary = build_operator_context(projects)
|
| 190 |
+
|
| 191 |
+
chat_system = (
|
| 192 |
+
"Tu es Oppy, l'assistant personnel vocal d'un etudiant en alternance. "
|
| 193 |
+
"Tu es en mode CONVERSATION. L'utilisateur te parle et te pose des questions. "
|
| 194 |
+
"Tu REPONDS UNIQUEMENT a ce qu'il demande. Tu ne fais JAMAIS de brief complet sauf s'il le demande explicitement.\n\n"
|
| 195 |
+
"REGLE ABSOLUE : lis la question de l'utilisateur et reponds SEULEMENT a cette question. "
|
| 196 |
+
"Si il demande son programme du jour, donne JUSTE les events du jour. "
|
| 197 |
+
"Si il demande un mail precis, resume JUSTE ce mail. "
|
| 198 |
+
"Si il demande de l'aide sur un projet, parle JUSTE de ce projet. "
|
| 199 |
+
"Ne liste JAMAIS tous les projets sauf si on te le demande.\n\n"
|
| 200 |
+
"Style :\n"
|
| 201 |
+
"- Parle en francais naturel, tutoie, sois direct comme un pote.\n"
|
| 202 |
+
"- Reponds en 2-5 phrases max. Court et percutant.\n"
|
| 203 |
+
"- Nomme les gens par leur prenom (Sophie, pas sophie.renard@bnpparibas.com).\n"
|
| 204 |
+
"- Termine par une action concrete ou une question de suivi.\n"
|
| 205 |
+
"- Ne repete JAMAIS une reponse precedente.\n"
|
| 206 |
+
"- Pas de tirets, pas de listes a puces. Des phrases.\n\n"
|
| 207 |
+
f"Projets actifs :\n{project_summary}\n\n"
|
| 208 |
+
f"Donnees completes :\n{data_context}"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
chat = client.chats.create(
|
| 212 |
+
model="gemini-3-flash-preview",
|
| 213 |
+
config=types.GenerateContentConfig(
|
| 214 |
+
system_instruction=chat_system,
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
return client, chat
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def chat_with_operator(message: str, chat_session, on_event: Callable) -> str:
|
| 221 |
+
"""Answer a user question using a persistent chat session."""
|
| 222 |
+
response = chat_session.send_message(message)
|
| 223 |
+
reply = response.text
|
| 224 |
+
|
| 225 |
+
await on_event({
|
| 226 |
+
"type": "chat_reply",
|
| 227 |
+
"text": reply,
|
| 228 |
+
})
|
| 229 |
+
|
| 230 |
+
return reply
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def run_operator(projects: list[dict], on_event: Callable) -> str:
|
| 234 |
+
"""Run the Oppy agent loop.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
projects: list of project dicts from config.json
|
| 238 |
+
on_event: async callback(event_dict) called for each step
|
| 239 |
+
|
| 240 |
+
Returns:
|
| 241 |
+
The final brief text.
|
| 242 |
+
"""
|
| 243 |
+
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
| 244 |
+
|
| 245 |
+
# Signal to frontend whether we're using live APIs
|
| 246 |
+
await on_event({
|
| 247 |
+
"type": "mode",
|
| 248 |
+
"live": is_authenticated(),
|
| 249 |
+
})
|
| 250 |
+
|
| 251 |
+
project_summary = build_operator_context(projects)
|
| 252 |
+
|
| 253 |
+
chat = client.chats.create(
|
| 254 |
+
model="gemini-2.5-flash",
|
| 255 |
+
config=types.GenerateContentConfig(
|
| 256 |
+
system_instruction=SYSTEM_PROMPT,
|
| 257 |
+
tools=TOOL_DECLARATIONS,
|
| 258 |
+
),
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
user_msg = (
|
| 262 |
+
f"Voici tes 3 projets actifs :\n{project_summary}\n\n"
|
| 263 |
+
"Commence par scanner chaque projet (emails, calendrier, web), "
|
| 264 |
+
"puis delivre ton brief proactif."
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
response = chat.send_message(user_msg)
|
| 268 |
+
iterations = 0
|
| 269 |
+
start_time = time.time()
|
| 270 |
+
collected_data = {}
|
| 271 |
+
|
| 272 |
+
while iterations < MAX_ITERATIONS and (time.time() - start_time) < TIMEOUT_SECONDS:
|
| 273 |
+
# Check for function calls
|
| 274 |
+
function_calls = []
|
| 275 |
+
for part in response.candidates[0].content.parts:
|
| 276 |
+
if part.function_call:
|
| 277 |
+
function_calls.append(part.function_call)
|
| 278 |
+
|
| 279 |
+
if not function_calls:
|
| 280 |
+
break
|
| 281 |
+
|
| 282 |
+
# Execute each function call
|
| 283 |
+
function_responses = []
|
| 284 |
+
for fc in function_calls:
|
| 285 |
+
tool_name = fc.name
|
| 286 |
+
tool_args = dict(fc.args) if fc.args else {}
|
| 287 |
+
|
| 288 |
+
await on_event({
|
| 289 |
+
"type": "tool_call",
|
| 290 |
+
"tool": tool_name,
|
| 291 |
+
"project": tool_args.get("project_id", tool_args.get("doc_id", "")),
|
| 292 |
+
"status": "running",
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
tool_fn = TOOL_FUNCTIONS.get(tool_name)
|
| 296 |
+
if tool_fn:
|
| 297 |
+
try:
|
| 298 |
+
result = tool_fn(**tool_args)
|
| 299 |
+
except Exception as e:
|
| 300 |
+
result = f"Error: {str(e)}"
|
| 301 |
+
else:
|
| 302 |
+
result = f"Unknown tool: {tool_name}"
|
| 303 |
+
|
| 304 |
+
pid = tool_args.get("project_id", "")
|
| 305 |
+
if pid:
|
| 306 |
+
if pid not in collected_data:
|
| 307 |
+
collected_data[pid] = {}
|
| 308 |
+
collected_data[pid][tool_name] = result
|
| 309 |
+
|
| 310 |
+
await on_event({
|
| 311 |
+
"type": "tool_result",
|
| 312 |
+
"tool": tool_name,
|
| 313 |
+
"project": pid or tool_args.get("doc_id", ""),
|
| 314 |
+
"result": result[:200],
|
| 315 |
+
"status": "done",
|
| 316 |
+
})
|
| 317 |
+
|
| 318 |
+
function_responses.append(
|
| 319 |
+
types.Part.from_function_response(
|
| 320 |
+
name=tool_name,
|
| 321 |
+
response={"result": result},
|
| 322 |
+
)
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
iterations += 1
|
| 326 |
+
|
| 327 |
+
# Send function results back to Gemini
|
| 328 |
+
response = chat.send_message(function_responses)
|
| 329 |
+
|
| 330 |
+
# --- Deterministic scoring phase ---
|
| 331 |
+
valid_pids = {p["id"] for p in projects}
|
| 332 |
+
evaluations = {}
|
| 333 |
+
for pid, data in collected_data.items():
|
| 334 |
+
if pid not in valid_pids:
|
| 335 |
+
continue
|
| 336 |
+
|
| 337 |
+
project = next(p for p in projects if p["id"] == pid)
|
| 338 |
+
keywords = project.get("keywords", [])
|
| 339 |
+
|
| 340 |
+
emails = _get_emails_for_scoring(pid, keywords=keywords)
|
| 341 |
+
scored_emails = score_emails(emails)
|
| 342 |
+
|
| 343 |
+
for e in scored_emails:
|
| 344 |
+
await on_event({
|
| 345 |
+
"type": "urgency",
|
| 346 |
+
"project": pid,
|
| 347 |
+
"email_subject": e["subject"],
|
| 348 |
+
"score": e["urgency_score"],
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
events = _get_events_for_scoring(pid, keywords=keywords)
|
| 352 |
+
search_score = score_urgency(data.get("search_web", ""))
|
| 353 |
+
|
| 354 |
+
evaluation = evaluate_project(project, scored_emails, events, search_score)
|
| 355 |
+
evaluations[pid] = {"evaluation": evaluation, "project": project}
|
| 356 |
+
|
| 357 |
+
await on_event({
|
| 358 |
+
"type": "initiative",
|
| 359 |
+
"project": pid,
|
| 360 |
+
"status": evaluation["status"],
|
| 361 |
+
"alerts": evaluation["alerts"],
|
| 362 |
+
})
|
| 363 |
+
|
| 364 |
+
# --- Final brief with initiative context ---
|
| 365 |
+
initiative_summary = ""
|
| 366 |
+
for pid, cached in evaluations.items():
|
| 367 |
+
evaluation = cached["evaluation"]
|
| 368 |
+
project = cached["project"]
|
| 369 |
+
initiative_summary += (
|
| 370 |
+
f"\n[{project.get('name', pid)}] Status: {evaluation['status']}\n"
|
| 371 |
+
f"Alertes: {'; '.join(evaluation['alerts']) if evaluation['alerts'] else 'aucune'}\n"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
brief_prompt = (
|
| 375 |
+
f"Voici les evaluations d'urgence de tes 3 projets :\n{initiative_summary}\n\n"
|
| 376 |
+
"Genere maintenant ton brief proactif final. Commence par le plus urgent. "
|
| 377 |
+
"5 bullets max par projet. Sois brutalement concis. Parle en francais."
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
brief_response = chat.send_message(brief_prompt)
|
| 381 |
+
brief_text = brief_response.text
|
| 382 |
+
|
| 383 |
+
await on_event({
|
| 384 |
+
"type": "brief",
|
| 385 |
+
"text": brief_text,
|
| 386 |
+
})
|
| 387 |
+
|
| 388 |
+
return brief_text
|
backend/auth_cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI script to authenticate Google account (manual code paste)."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from google.oauth2.credentials import Credentials
|
| 7 |
+
from google_auth_oauthlib.flow import Flow
|
| 8 |
+
|
| 9 |
+
SCOPES = [
|
| 10 |
+
"https://www.googleapis.com/auth/gmail.readonly",
|
| 11 |
+
"https://www.googleapis.com/auth/calendar.readonly",
|
| 12 |
+
"https://www.googleapis.com/auth/documents.readonly",
|
| 13 |
+
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
CLIENT_SECRET_PATH = Path(__file__).parent / "client_secret.json"
|
| 17 |
+
TOKEN_PATH = Path(__file__).parent / "token.json"
|
| 18 |
+
|
| 19 |
+
REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
with open(CLIENT_SECRET_PATH) as f:
|
| 24 |
+
client_config = json.load(f)
|
| 25 |
+
|
| 26 |
+
if "web" in client_config:
|
| 27 |
+
web = client_config["web"]
|
| 28 |
+
config = {
|
| 29 |
+
"installed": {
|
| 30 |
+
"client_id": web["client_id"],
|
| 31 |
+
"client_secret": web["client_secret"],
|
| 32 |
+
"auth_uri": web["auth_uri"],
|
| 33 |
+
"token_uri": web["token_uri"],
|
| 34 |
+
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"],
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
else:
|
| 38 |
+
config = client_config
|
| 39 |
+
|
| 40 |
+
flow = Flow.from_client_config(config, scopes=SCOPES, redirect_uri="http://localhost:8091/")
|
| 41 |
+
|
| 42 |
+
auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
|
| 43 |
+
|
| 44 |
+
print("\n=== Google OAuth ===")
|
| 45 |
+
print(f"\nOpen this URL in your browser:\n\n{auth_url}\n")
|
| 46 |
+
print("After granting permissions, you'll be redirected to localhost.")
|
| 47 |
+
print("Copy the FULL URL from your browser address bar and paste it here.\n")
|
| 48 |
+
|
| 49 |
+
redirect_url = input("Paste the redirect URL here: ").strip()
|
| 50 |
+
|
| 51 |
+
flow.fetch_token(authorization_response=redirect_url)
|
| 52 |
+
creds = flow.credentials
|
| 53 |
+
|
| 54 |
+
TOKEN_PATH.write_text(creds.to_json())
|
| 55 |
+
print(f"\nToken saved to {TOKEN_PATH}")
|
| 56 |
+
print("You're authenticated! All Google API tools are now live.")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
main()
|
backend/config.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mode": "live",
|
| 3 |
+
"projects": [
|
| 4 |
+
{
|
| 5 |
+
"id": "school",
|
| 6 |
+
"name": "Master IA \u2014 Sorbonne",
|
| 7 |
+
"contact": "whung@albertschool.com",
|
| 8 |
+
"deadline": "2026-03-18T23:59:00",
|
| 9 |
+
"color": "#00FF88",
|
| 10 |
+
"keywords": ["cours", "rendu", "memoire", "soutenance", "TP", "Deep Learning"]
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "company",
|
| 14 |
+
"name": "Alternance \u2014 BNP Paribas",
|
| 15 |
+
"contact": "wei_ling.hung@edu.escp.eu",
|
| 16 |
+
"deadline": "2026-03-17T09:00:00",
|
| 17 |
+
"color": "#FF4444",
|
| 18 |
+
"keywords": ["sprint", "daily", "jira", "livrable", "prod", "dashboard", "bloquant"]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "startup",
|
| 22 |
+
"name": "Side Project \u2014 NoctaAI",
|
| 23 |
+
"contact": "weilinghung99@gmail.com",
|
| 24 |
+
"deadline": "2026-04-05T00:00:00",
|
| 25 |
+
"color": "#FF8800",
|
| 26 |
+
"keywords": ["NoctaAI", "MVP", "landing", "beta", "funding"]
|
| 27 |
+
}
|
| 28 |
+
]
|
| 29 |
+
}
|
backend/google_auth.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google OAuth 2.0 flow for Gmail, Calendar, and Docs APIs."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from google_auth_oauthlib.flow import Flow
|
| 8 |
+
from google.oauth2.credentials import Credentials
|
| 9 |
+
from google.auth.transport.requests import Request
|
| 10 |
+
|
| 11 |
+
SCOPES = [
|
| 12 |
+
"https://www.googleapis.com/auth/gmail.readonly",
|
| 13 |
+
"https://www.googleapis.com/auth/calendar.readonly",
|
| 14 |
+
"https://www.googleapis.com/auth/documents.readonly",
|
| 15 |
+
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
TOKEN_PATH = Path(__file__).parent / "token.json"
|
| 19 |
+
CLIENT_SECRET_PATH = Path(__file__).parent / "client_secret.json"
|
| 20 |
+
|
| 21 |
+
# In-memory credentials cache (single-user demo)
|
| 22 |
+
_credentials: Credentials | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _get_redirect_uri() -> str:
|
| 26 |
+
return os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/auth/callback")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def has_client_secret() -> bool:
|
| 30 |
+
"""Check if OAuth client_secret.json is configured."""
|
| 31 |
+
return CLIENT_SECRET_PATH.exists()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_auth_url() -> str | None:
|
| 35 |
+
"""Generate the Google OAuth consent URL."""
|
| 36 |
+
if not has_client_secret():
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
flow = Flow.from_client_secrets_file(
|
| 40 |
+
str(CLIENT_SECRET_PATH),
|
| 41 |
+
scopes=SCOPES,
|
| 42 |
+
redirect_uri=_get_redirect_uri(),
|
| 43 |
+
)
|
| 44 |
+
auth_url, _ = flow.authorization_url(
|
| 45 |
+
access_type="offline",
|
| 46 |
+
include_granted_scopes="true",
|
| 47 |
+
prompt="consent",
|
| 48 |
+
)
|
| 49 |
+
return auth_url
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def handle_callback(authorization_code: str) -> Credentials:
|
| 53 |
+
"""Exchange the authorization code for credentials."""
|
| 54 |
+
global _credentials
|
| 55 |
+
|
| 56 |
+
flow = Flow.from_client_secrets_file(
|
| 57 |
+
str(CLIENT_SECRET_PATH),
|
| 58 |
+
scopes=SCOPES,
|
| 59 |
+
redirect_uri=_get_redirect_uri(),
|
| 60 |
+
)
|
| 61 |
+
flow.fetch_token(code=authorization_code)
|
| 62 |
+
_credentials = flow.credentials
|
| 63 |
+
|
| 64 |
+
# Persist token for reuse across restarts
|
| 65 |
+
TOKEN_PATH.write_text(_credentials.to_json())
|
| 66 |
+
return _credentials
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_credentials() -> Credentials | None:
|
| 70 |
+
"""Get valid credentials, refreshing if needed."""
|
| 71 |
+
global _credentials
|
| 72 |
+
|
| 73 |
+
if _credentials and _credentials.valid:
|
| 74 |
+
return _credentials
|
| 75 |
+
|
| 76 |
+
if _credentials and _credentials.expired and _credentials.refresh_token:
|
| 77 |
+
_credentials.refresh(Request())
|
| 78 |
+
TOKEN_PATH.write_text(_credentials.to_json())
|
| 79 |
+
return _credentials
|
| 80 |
+
|
| 81 |
+
# Try loading from persisted token
|
| 82 |
+
if TOKEN_PATH.exists():
|
| 83 |
+
_credentials = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
| 84 |
+
if _credentials.valid:
|
| 85 |
+
return _credentials
|
| 86 |
+
if _credentials.expired and _credentials.refresh_token:
|
| 87 |
+
_credentials.refresh(Request())
|
| 88 |
+
TOKEN_PATH.write_text(_credentials.to_json())
|
| 89 |
+
return _credentials
|
| 90 |
+
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def is_authenticated() -> bool:
|
| 95 |
+
"""Check if we have valid Google credentials."""
|
| 96 |
+
return get_credentials() is not None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def logout():
|
| 100 |
+
"""Clear stored credentials."""
|
| 101 |
+
global _credentials
|
| 102 |
+
_credentials = None
|
| 103 |
+
if TOKEN_PATH.exists():
|
| 104 |
+
TOKEN_PATH.unlink()
|
backend/google_services.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Real Google API wrappers for Gmail, Calendar, Docs, and Search."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
from google import genai
|
| 10 |
+
from google.genai import types
|
| 11 |
+
from googleapiclient.discovery import build
|
| 12 |
+
|
| 13 |
+
from google_auth import get_credentials
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _get_gmail_service():
|
| 19 |
+
creds = get_credentials()
|
| 20 |
+
if not creds:
|
| 21 |
+
return None
|
| 22 |
+
return build("gmail", "v1", credentials=creds)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _get_calendar_service():
|
| 26 |
+
creds = get_credentials()
|
| 27 |
+
if not creds:
|
| 28 |
+
return None
|
| 29 |
+
return build("calendar", "v3", credentials=creds)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_docs_service():
|
| 33 |
+
creds = get_credentials()
|
| 34 |
+
if not creds:
|
| 35 |
+
return None
|
| 36 |
+
return build("docs", "v1", credentials=creds)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _extract_body(payload: dict) -> str:
|
| 40 |
+
"""Extract plain text body from Gmail message payload."""
|
| 41 |
+
if payload.get("body", {}).get("data"):
|
| 42 |
+
return base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace")
|
| 43 |
+
|
| 44 |
+
parts = payload.get("parts", [])
|
| 45 |
+
for part in parts:
|
| 46 |
+
if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):
|
| 47 |
+
return base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")
|
| 48 |
+
# Recurse into multipart
|
| 49 |
+
if part.get("parts"):
|
| 50 |
+
result = _extract_body(part)
|
| 51 |
+
if result:
|
| 52 |
+
return result
|
| 53 |
+
|
| 54 |
+
return ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _get_header(headers: list[dict], name: str) -> str:
|
| 58 |
+
"""Get a header value by name from Gmail message headers."""
|
| 59 |
+
for h in headers:
|
| 60 |
+
if h["name"].lower() == name.lower():
|
| 61 |
+
return h["value"]
|
| 62 |
+
return ""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _days_since(date_str: str) -> int | None:
|
| 66 |
+
"""Calculate days since an email date. Returns None if unparseable."""
|
| 67 |
+
try:
|
| 68 |
+
# Gmail dates are like "Fri, 14 Mar 2026 10:30:00 +0100"
|
| 69 |
+
# Try multiple formats
|
| 70 |
+
for fmt in ["%a, %d %b %Y %H:%M:%S %z", "%d %b %Y %H:%M:%S %z"]:
|
| 71 |
+
try:
|
| 72 |
+
dt = datetime.strptime(date_str, fmt)
|
| 73 |
+
return (datetime.now(timezone.utc) - dt).days
|
| 74 |
+
except ValueError:
|
| 75 |
+
continue
|
| 76 |
+
return None
|
| 77 |
+
except Exception:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def fetch_emails(project_id: str, keywords: list[str] | None = None, max_results: int = 10) -> list[dict]:
|
| 82 |
+
"""Fetch recent emails from Gmail, optionally filtered by project keywords.
|
| 83 |
+
|
| 84 |
+
Returns list of dicts with: from, subject, body, date, days_since_reply
|
| 85 |
+
"""
|
| 86 |
+
service = _get_gmail_service()
|
| 87 |
+
if not service:
|
| 88 |
+
return []
|
| 89 |
+
|
| 90 |
+
# Build search query from project keywords
|
| 91 |
+
query = ""
|
| 92 |
+
if keywords:
|
| 93 |
+
query = " OR ".join(keywords)
|
| 94 |
+
query = f"({query})"
|
| 95 |
+
query += " newer_than:14d"
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
results = service.users().messages().list(
|
| 99 |
+
userId="me",
|
| 100 |
+
q=query.strip(),
|
| 101 |
+
maxResults=max_results,
|
| 102 |
+
).execute()
|
| 103 |
+
|
| 104 |
+
messages = results.get("messages", [])
|
| 105 |
+
emails = []
|
| 106 |
+
|
| 107 |
+
for msg_meta in messages:
|
| 108 |
+
msg = service.users().messages().get(
|
| 109 |
+
userId="me",
|
| 110 |
+
id=msg_meta["id"],
|
| 111 |
+
format="full",
|
| 112 |
+
).execute()
|
| 113 |
+
|
| 114 |
+
headers = msg.get("payload", {}).get("headers", [])
|
| 115 |
+
from_addr = _get_header(headers, "From")
|
| 116 |
+
subject = _get_header(headers, "Subject")
|
| 117 |
+
date_str = _get_header(headers, "Date")
|
| 118 |
+
body = _extract_body(msg.get("payload", {}))
|
| 119 |
+
|
| 120 |
+
# Truncate body for context window efficiency
|
| 121 |
+
if len(body) > 500:
|
| 122 |
+
body = body[:500] + "..."
|
| 123 |
+
|
| 124 |
+
days = _days_since(date_str)
|
| 125 |
+
|
| 126 |
+
emails.append({
|
| 127 |
+
"from": from_addr,
|
| 128 |
+
"subject": subject,
|
| 129 |
+
"body": body,
|
| 130 |
+
"date": date_str,
|
| 131 |
+
"days_since_reply": days,
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
return emails
|
| 135 |
+
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"Gmail API error: {e}")
|
| 138 |
+
return []
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def fetch_events(project_id: str, keywords: list[str] | None = None, days_ahead: int = 7) -> list[dict]:
|
| 142 |
+
"""Fetch upcoming calendar events, optionally filtered by keywords.
|
| 143 |
+
|
| 144 |
+
Returns list of dicts with: title, time, prep_block
|
| 145 |
+
"""
|
| 146 |
+
service = _get_calendar_service()
|
| 147 |
+
if not service:
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
now = datetime.now(timezone.utc)
|
| 151 |
+
time_min = now.isoformat()
|
| 152 |
+
time_max = (now + timedelta(days=days_ahead)).isoformat()
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
# Fetch all events, filter locally by keywords for better matching
|
| 156 |
+
results = service.events().list(
|
| 157 |
+
calendarId="primary",
|
| 158 |
+
timeMin=time_min,
|
| 159 |
+
timeMax=time_max,
|
| 160 |
+
maxResults=30,
|
| 161 |
+
singleEvents=True,
|
| 162 |
+
orderBy="startTime",
|
| 163 |
+
).execute()
|
| 164 |
+
|
| 165 |
+
items = results.get("items", [])
|
| 166 |
+
events = []
|
| 167 |
+
|
| 168 |
+
for item in items:
|
| 169 |
+
start = item["start"].get("dateTime", item["start"].get("date", ""))
|
| 170 |
+
title = item.get("summary", "Sans titre")
|
| 171 |
+
|
| 172 |
+
# Filter by keywords if provided (case-insensitive, any keyword matches)
|
| 173 |
+
if keywords:
|
| 174 |
+
title_lower = title.lower()
|
| 175 |
+
if not any(k.lower() in title_lower for k in keywords):
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
# Detect prep blocks
|
| 179 |
+
is_prep = bool(re.search(r"prep|preparation|revision", title, re.IGNORECASE))
|
| 180 |
+
|
| 181 |
+
events.append({
|
| 182 |
+
"title": title,
|
| 183 |
+
"time": start,
|
| 184 |
+
"prep_block": is_prep,
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
return events
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f"Calendar API error: {e}")
|
| 191 |
+
return []
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def fetch_doc_content(doc_id: str) -> dict:
|
| 195 |
+
"""Fetch a Google Doc's title and text content.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
doc_id: The Google Docs document ID (from the URL).
|
| 199 |
+
|
| 200 |
+
Returns dict with: title, content (plain text excerpt)
|
| 201 |
+
"""
|
| 202 |
+
service = _get_docs_service()
|
| 203 |
+
if not service:
|
| 204 |
+
return {"title": "", "content": "Not authenticated with Google."}
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
doc = service.documents().get(documentId=doc_id).execute()
|
| 208 |
+
title = doc.get("title", "Untitled")
|
| 209 |
+
|
| 210 |
+
# Extract plain text from document body
|
| 211 |
+
body = doc.get("body", {})
|
| 212 |
+
content_parts = []
|
| 213 |
+
|
| 214 |
+
for element in body.get("content", []):
|
| 215 |
+
paragraph = element.get("paragraph")
|
| 216 |
+
if paragraph:
|
| 217 |
+
for elem in paragraph.get("elements", []):
|
| 218 |
+
text_run = elem.get("textRun")
|
| 219 |
+
if text_run:
|
| 220 |
+
content_parts.append(text_run.get("content", ""))
|
| 221 |
+
|
| 222 |
+
full_text = "".join(content_parts).strip()
|
| 223 |
+
|
| 224 |
+
# Truncate for context efficiency
|
| 225 |
+
if len(full_text) > 2000:
|
| 226 |
+
full_text = full_text[:2000] + "..."
|
| 227 |
+
|
| 228 |
+
return {"title": title, "content": full_text}
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
print(f"Docs API error: {e}")
|
| 232 |
+
return {"title": "", "content": f"Error reading document: {e}"}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def list_recent_docs(max_results: int = 5) -> list[dict]:
|
| 236 |
+
"""List recently modified Google Docs using the Drive API.
|
| 237 |
+
|
| 238 |
+
Returns list of dicts with: id, title, modified_time
|
| 239 |
+
"""
|
| 240 |
+
creds = get_credentials()
|
| 241 |
+
if not creds:
|
| 242 |
+
return []
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
drive = build("drive", "v3", credentials=creds)
|
| 246 |
+
results = drive.files().list(
|
| 247 |
+
q="mimeType='application/vnd.google-apps.document'",
|
| 248 |
+
orderBy="modifiedTime desc",
|
| 249 |
+
pageSize=max_results,
|
| 250 |
+
fields="files(id, name, modifiedTime)",
|
| 251 |
+
).execute()
|
| 252 |
+
|
| 253 |
+
files = results.get("files", [])
|
| 254 |
+
return [
|
| 255 |
+
{"id": f["id"], "title": f["name"], "modified_time": f["modifiedTime"]}
|
| 256 |
+
for f in files
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
except Exception as e:
|
| 260 |
+
print(f"Drive API error: {e}")
|
| 261 |
+
return []
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def search_project_signals(project_name: str, keywords: list[str] | None = None) -> str:
|
| 265 |
+
"""Search the web for recent signals relevant to a project using Gemini + Google Search grounding.
|
| 266 |
+
|
| 267 |
+
Returns a concise summary of external signals (news, funding, announcements).
|
| 268 |
+
"""
|
| 269 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 270 |
+
if not api_key:
|
| 271 |
+
return ""
|
| 272 |
+
|
| 273 |
+
search_terms = project_name
|
| 274 |
+
if keywords:
|
| 275 |
+
search_terms += " " + " ".join(keywords[:3])
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
client = genai.Client(api_key=api_key)
|
| 279 |
+
response = client.models.generate_content(
|
| 280 |
+
model="gemini-2.5-flash",
|
| 281 |
+
contents=(
|
| 282 |
+
f"Recherche les actualites recentes et signaux externes pertinents pour : {search_terms}. "
|
| 283 |
+
"Donne 2-3 bullet points courts en francais. Seulement les infos factuelles et recentes."
|
| 284 |
+
),
|
| 285 |
+
config=types.GenerateContentConfig(
|
| 286 |
+
tools=[types.Tool(google_search=types.GoogleSearch())],
|
| 287 |
+
),
|
| 288 |
+
)
|
| 289 |
+
return response.text.strip() if response.text else ""
|
| 290 |
+
except Exception as e:
|
| 291 |
+
print(f"Google Search grounding error: {e}")
|
| 292 |
+
return ""
|
backend/initiative.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def evaluate_project(project: dict, emails: list[dict], events: list[dict], search_score: float) -> dict:
|
| 5 |
+
"""Evaluate a project's status based on deterministic rules.
|
| 6 |
+
|
| 7 |
+
Returns:
|
| 8 |
+
{"status": "READY"|"URGENT"|"SIGNAL", "alerts": [str]}
|
| 9 |
+
"""
|
| 10 |
+
alerts = []
|
| 11 |
+
status = "READY"
|
| 12 |
+
|
| 13 |
+
# Rule 1: Silence detected
|
| 14 |
+
reply_days = [
|
| 15 |
+
e["days_since_reply"]
|
| 16 |
+
for e in emails
|
| 17 |
+
if e.get("days_since_reply") is not None
|
| 18 |
+
]
|
| 19 |
+
if reply_days:
|
| 20 |
+
max_silence = max(reply_days)
|
| 21 |
+
if max_silence >= 5:
|
| 22 |
+
contact = emails[0]["from"]
|
| 23 |
+
alerts.append(f"Silence depuis {max_silence} jours de {contact}")
|
| 24 |
+
status = "URGENT"
|
| 25 |
+
|
| 26 |
+
# Rule 2: Deadline < 48h without prep block
|
| 27 |
+
deadline_str = project.get("deadline")
|
| 28 |
+
if not deadline_str:
|
| 29 |
+
return {"status": status, "alerts": alerts}
|
| 30 |
+
deadline = datetime.fromisoformat(deadline_str)
|
| 31 |
+
now = datetime.now()
|
| 32 |
+
hours_to_deadline = (deadline - now).total_seconds() / 3600
|
| 33 |
+
has_prep = any(e.get("prep_block", False) for e in events)
|
| 34 |
+
if hours_to_deadline < 48 and not has_prep:
|
| 35 |
+
alerts.append(f"Deadline dans {int(hours_to_deadline)}h - aucun bloc de prep")
|
| 36 |
+
status = "URGENT"
|
| 37 |
+
|
| 38 |
+
# Rule 3: External signal relevant
|
| 39 |
+
if search_score > 0.6:
|
| 40 |
+
alerts.append("Signal externe pertinent detecte")
|
| 41 |
+
if status != "URGENT":
|
| 42 |
+
status = "SIGNAL"
|
| 43 |
+
|
| 44 |
+
# Rule 4: High urgency email
|
| 45 |
+
urgency_scores = [e.get("urgency_score", 0) for e in emails]
|
| 46 |
+
if any(s > 0.8 for s in urgency_scores):
|
| 47 |
+
alerts.append("Email a haute urgence detecte")
|
| 48 |
+
status = "URGENT"
|
| 49 |
+
|
| 50 |
+
return {"status": status, "alerts": alerts}
|
backend/main.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Query
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import FileResponse, Response, RedirectResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from sse_starlette.sse import EventSourceResponse
|
| 15 |
+
|
| 16 |
+
from agent import chat_with_operator, create_chat_session, run_operator
|
| 17 |
+
from google_auth import get_auth_url, handle_callback, has_client_secret, is_authenticated, logout
|
| 18 |
+
from mock_data import MOCK_EMAILS, MOCK_EVENTS, MOCK_SEARCH
|
| 19 |
+
from tts import generate_speech
|
| 20 |
+
from urgency import load_model
|
| 21 |
+
|
| 22 |
+
load_dotenv()
|
| 23 |
+
|
| 24 |
+
# Fail fast if Gemini API key is missing
|
| 25 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 26 |
+
if not api_key or api_key == "your_key_here":
|
| 27 |
+
raise RuntimeError("GOOGLE_API_KEY not set. Check backend/.env")
|
| 28 |
+
|
| 29 |
+
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Persistent chat session (created on first /api/chat call)
|
| 33 |
+
_chat_client = None
|
| 34 |
+
_chat_session = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_chat_session():
|
| 38 |
+
global _chat_client, _chat_session
|
| 39 |
+
if _chat_session is None:
|
| 40 |
+
config = load_config()
|
| 41 |
+
_chat_client, _chat_session = create_chat_session(config["projects"])
|
| 42 |
+
return _chat_session
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@asynccontextmanager
|
| 46 |
+
async def lifespan(app):
|
| 47 |
+
load_model()
|
| 48 |
+
print("HuggingFace model loaded.")
|
| 49 |
+
if has_client_secret():
|
| 50 |
+
print("OAuth client_secret.json found. Google API integration enabled.")
|
| 51 |
+
else:
|
| 52 |
+
print("No client_secret.json found. Running in mock/demo mode.")
|
| 53 |
+
yield
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
app = FastAPI(title="Oppy", lifespan=lifespan)
|
| 57 |
+
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=["*"],
|
| 61 |
+
allow_credentials=True,
|
| 62 |
+
allow_methods=["*"],
|
| 63 |
+
allow_headers=["*"],
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_config():
|
| 68 |
+
from pathlib import Path
|
| 69 |
+
config_path = Path(__file__).parent / "config.json"
|
| 70 |
+
with open(config_path) as f:
|
| 71 |
+
return json.load(f)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---- OAuth Endpoints ----
|
| 75 |
+
|
| 76 |
+
@app.get("/api/auth/status")
|
| 77 |
+
async def auth_status():
|
| 78 |
+
"""Check if user is authenticated with Google APIs."""
|
| 79 |
+
return {
|
| 80 |
+
"authenticated": is_authenticated(),
|
| 81 |
+
"oauth_available": has_client_secret(),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.get("/api/auth/login")
|
| 86 |
+
async def auth_login():
|
| 87 |
+
"""Redirect to Google OAuth consent screen."""
|
| 88 |
+
url = get_auth_url()
|
| 89 |
+
if not url:
|
| 90 |
+
return {"error": "OAuth not configured. Place client_secret.json in backend/."}
|
| 91 |
+
return RedirectResponse(url)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.get("/api/auth/callback")
|
| 95 |
+
async def auth_callback(code: str = Query(...)):
|
| 96 |
+
"""Handle Google OAuth callback."""
|
| 97 |
+
handle_callback(code)
|
| 98 |
+
return RedirectResponse(f"{FRONTEND_URL}?auth=success")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/api/auth/logout")
|
| 102 |
+
async def auth_logout():
|
| 103 |
+
"""Clear Google credentials."""
|
| 104 |
+
logout()
|
| 105 |
+
return {"status": "logged_out"}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---- Core Endpoints ----
|
| 109 |
+
|
| 110 |
+
@app.get("/api/run")
|
| 111 |
+
async def run():
|
| 112 |
+
config = load_config()
|
| 113 |
+
queue = asyncio.Queue()
|
| 114 |
+
|
| 115 |
+
async def on_event(event: dict):
|
| 116 |
+
await queue.put(event)
|
| 117 |
+
|
| 118 |
+
async def generator():
|
| 119 |
+
task = asyncio.create_task(run_operator(config["projects"], on_event))
|
| 120 |
+
|
| 121 |
+
while True:
|
| 122 |
+
try:
|
| 123 |
+
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
| 124 |
+
yield {"event": event["type"], "data": json.dumps(event, ensure_ascii=False)}
|
| 125 |
+
if event["type"] == "brief":
|
| 126 |
+
break
|
| 127 |
+
except asyncio.TimeoutError:
|
| 128 |
+
if task.done():
|
| 129 |
+
break
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
await task
|
| 133 |
+
|
| 134 |
+
return EventSourceResponse(generator())
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TTSRequest(BaseModel):
|
| 138 |
+
text: str
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class ChatRequest(BaseModel):
|
| 142 |
+
message: str
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.post("/api/tts")
|
| 146 |
+
async def tts(req: TTSRequest):
|
| 147 |
+
audio_bytes = generate_speech(req.text)
|
| 148 |
+
return Response(content=audio_bytes, media_type="audio/wav")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.post("/api/chat")
|
| 152 |
+
async def chat(req: ChatRequest):
|
| 153 |
+
session = get_chat_session()
|
| 154 |
+
queue = asyncio.Queue()
|
| 155 |
+
|
| 156 |
+
async def on_event(event: dict):
|
| 157 |
+
await queue.put(event)
|
| 158 |
+
|
| 159 |
+
async def generator():
|
| 160 |
+
task = asyncio.create_task(
|
| 161 |
+
chat_with_operator(req.message, session, on_event)
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
while True:
|
| 165 |
+
try:
|
| 166 |
+
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
| 167 |
+
yield {"event": event["type"], "data": json.dumps(event, ensure_ascii=False)}
|
| 168 |
+
if event["type"] == "chat_reply":
|
| 169 |
+
break
|
| 170 |
+
except asyncio.TimeoutError:
|
| 171 |
+
if task.done():
|
| 172 |
+
break
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
await task
|
| 176 |
+
|
| 177 |
+
return EventSourceResponse(generator())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@app.get("/api/project/{project_id}/sources")
|
| 181 |
+
async def project_sources(project_id: str):
|
| 182 |
+
emails = MOCK_EMAILS.get(project_id, [])
|
| 183 |
+
events = MOCK_EVENTS.get(project_id, [])
|
| 184 |
+
search = MOCK_SEARCH.get(project_id, "")
|
| 185 |
+
return {
|
| 186 |
+
"emails": emails,
|
| 187 |
+
"events": events,
|
| 188 |
+
"search": search,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@app.get("/api/health")
|
| 193 |
+
async def health():
|
| 194 |
+
return {
|
| 195 |
+
"status": "ok",
|
| 196 |
+
"google_authenticated": is_authenticated(),
|
| 197 |
+
"oauth_available": has_client_secret(),
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# --- Serve frontend static files (for Replit / production) ---
|
| 202 |
+
_static_dir = Path(__file__).parent / "static"
|
| 203 |
+
if _static_dir.exists():
|
| 204 |
+
app.mount("/assets", StaticFiles(directory=_static_dir / "assets"), name="assets")
|
| 205 |
+
|
| 206 |
+
@app.get("/{full_path:path}")
|
| 207 |
+
async def serve_spa(full_path: str):
|
| 208 |
+
"""Serve the React SPA for any non-API route."""
|
| 209 |
+
file_path = _static_dir / full_path
|
| 210 |
+
if file_path.is_file():
|
| 211 |
+
return FileResponse(file_path)
|
| 212 |
+
return FileResponse(_static_dir / "index.html")
|
backend/mock_data.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MOCK_EMAILS = {
|
| 2 |
+
"school": [
|
| 3 |
+
{
|
| 4 |
+
"from": "prof.martinez@sorbonne.fr",
|
| 5 |
+
"subject": "Rendu TP Deep Learning",
|
| 6 |
+
"body": "Le rendu du TP sur les transformers est pour mercredi 18 mars 23h59. Format notebook + rapport PDF. Pas d'extension possible.",
|
| 7 |
+
"date": "2026-03-12",
|
| 8 |
+
"days_since_reply": 0,
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"from": "admin-master@sorbonne.fr",
|
| 12 |
+
"subject": "Soutenance memoire - date fixee",
|
| 13 |
+
"body": "Votre soutenance est fixee au 15 avril. Merci de confirmer votre sujet avant le 25 mars.",
|
| 14 |
+
"date": "2026-03-13",
|
| 15 |
+
"days_since_reply": 1,
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"from": "prof.dubois@sorbonne.fr",
|
| 19 |
+
"subject": "Projet NLP - constitution des groupes",
|
| 20 |
+
"body": "Les groupes pour le projet NLP doivent etre constitues avant vendredi. 3 personnes max. Envoyez-moi vos groupes par mail.",
|
| 21 |
+
"date": "2026-03-14",
|
| 22 |
+
"days_since_reply": 0,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"from": "bde-ia@sorbonne.fr",
|
| 26 |
+
"subject": "Hackathon IA Sorbonne - 22 mars",
|
| 27 |
+
"body": "Le hackathon annuel du Master IA aura lieu le 22 mars. Inscriptions ouvertes, equipes de 4. Theme : IA generative appliquee a la sante.",
|
| 28 |
+
"date": "2026-03-14",
|
| 29 |
+
"days_since_reply": None,
|
| 30 |
+
},
|
| 31 |
+
],
|
| 32 |
+
"company": [
|
| 33 |
+
{
|
| 34 |
+
"from": "sophie.renard@bnpparibas.com",
|
| 35 |
+
"subject": "Re: Dashboard analytics - feedback",
|
| 36 |
+
"body": "Le product owner attend les corrections sur le dashboard avant lundi matin. Les KPIs ne remontent pas correctement en prod. C'est bloquant pour la review sprint.",
|
| 37 |
+
"date": "2026-03-08",
|
| 38 |
+
"days_since_reply": 6,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"from": "tech-lead@bnpparibas.com",
|
| 42 |
+
"subject": "Daily standup notes",
|
| 43 |
+
"body": "Action item pour toi : fixer le bug sur le filtre date du dashboard. Sprint review mardi.",
|
| 44 |
+
"date": "2026-03-13",
|
| 45 |
+
"days_since_reply": 1,
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"from": "rh@bnpparibas.com",
|
| 49 |
+
"subject": "Convention alternance - signature requise",
|
| 50 |
+
"body": "Merci de signer et retourner votre convention d'alternance avant le 20 mars. Document en piece jointe.",
|
| 51 |
+
"date": "2026-03-12",
|
| 52 |
+
"days_since_reply": 2,
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"from": "sophie.renard@bnpparibas.com",
|
| 56 |
+
"subject": "Formation Power BI - jeudi 14h",
|
| 57 |
+
"body": "N'oublie pas la formation Power BI jeudi a 14h en salle 3B. C'est obligatoire pour tous les alternants data.",
|
| 58 |
+
"date": "2026-03-13",
|
| 59 |
+
"days_since_reply": 0,
|
| 60 |
+
},
|
| 61 |
+
],
|
| 62 |
+
"startup": [
|
| 63 |
+
{
|
| 64 |
+
"from": "yassine@noctaai.com",
|
| 65 |
+
"subject": "Re: Landing page v2",
|
| 66 |
+
"body": "La landing est live mais le taux de conversion est a 0.8%. On doit refaire le hero. Tu peux t'en occuper ce weekend ?",
|
| 67 |
+
"date": "2026-03-11",
|
| 68 |
+
"days_since_reply": 3,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"from": "newsletter@techcrunch.com",
|
| 72 |
+
"subject": "Y Combinator ouvre les candidatures W26",
|
| 73 |
+
"body": "YC Winter 2026 applications are now open. Deadline: April 10. Focus on AI-native startups.",
|
| 74 |
+
"date": "2026-03-14",
|
| 75 |
+
"days_since_reply": None,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"from": "investisseur@station-f.co",
|
| 79 |
+
"subject": "Suite a notre echange - NoctaAI",
|
| 80 |
+
"body": "Merci pour le pitch de mardi. On aimerait voir une demo live de votre produit. Dispo semaine prochaine ?",
|
| 81 |
+
"date": "2026-03-13",
|
| 82 |
+
"days_since_reply": 1,
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"from": "yassine@noctaai.com",
|
| 86 |
+
"subject": "Roadmap produit Q2",
|
| 87 |
+
"body": "J'ai mis a jour la roadmap sur Notion. On doit prioriser : auth Google, integration Slack, et le mode offline. On en parle dimanche ?",
|
| 88 |
+
"date": "2026-03-14",
|
| 89 |
+
"days_since_reply": 0,
|
| 90 |
+
},
|
| 91 |
+
],
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
MOCK_EVENTS = {
|
| 95 |
+
"school": [
|
| 96 |
+
{"title": "TP Deep Learning - rendu", "time": "2026-03-18T23:59", "prep_block": False},
|
| 97 |
+
{"title": "Cours NLP avance", "time": "2026-03-15T09:00", "prep_block": True},
|
| 98 |
+
{"title": "TD Maths pour le ML", "time": "2026-03-14T14:00", "prep_block": False},
|
| 99 |
+
{"title": "Reunion groupe projet NLP", "time": "2026-03-16T11:00", "prep_block": False},
|
| 100 |
+
{"title": "Hackathon IA Sorbonne", "time": "2026-03-22T09:00", "prep_block": False},
|
| 101 |
+
],
|
| 102 |
+
"company": [
|
| 103 |
+
{"title": "Sprint Review", "time": "2026-03-17T10:00", "prep_block": False},
|
| 104 |
+
{"title": "Daily Standup", "time": "2026-03-14T09:30", "prep_block": True},
|
| 105 |
+
{"title": "Formation Power BI", "time": "2026-03-14T14:00", "prep_block": False},
|
| 106 |
+
{"title": "1:1 avec Sophie", "time": "2026-03-15T11:00", "prep_block": False},
|
| 107 |
+
{"title": "Demo client interne", "time": "2026-03-19T15:00", "prep_block": False},
|
| 108 |
+
],
|
| 109 |
+
"startup": [
|
| 110 |
+
{"title": "Call investisseur Station F", "time": "2026-03-17T18:00", "prep_block": False},
|
| 111 |
+
{"title": "Sync produit avec Yassine", "time": "2026-03-16T20:00", "prep_block": False},
|
| 112 |
+
{"title": "Deadline YC W26", "time": "2026-04-10T23:59", "prep_block": False},
|
| 113 |
+
],
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
MOCK_SEARCH = {
|
| 117 |
+
"school": "Sorbonne Universite - les inscriptions au Master IA 2026 battent des records, +40% de candidatures.",
|
| 118 |
+
"company": "BNP Paribas lance un nouveau programme d'acceleration data & IA pour ses alternants.",
|
| 119 |
+
"startup": "Y Combinator ouvre les candidatures Winter 2026. Deadline 10 avril. Focus AI-native startups.",
|
| 120 |
+
}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
google-genai>=1.0.0
|
| 2 |
+
sentence-transformers>=3.0.0
|
| 3 |
+
fastapi>=0.115.0
|
| 4 |
+
uvicorn>=0.30.0
|
| 5 |
+
sse-starlette>=2.0.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
google-auth>=2.0.0
|
| 8 |
+
google-auth-oauthlib>=1.0.0
|
| 9 |
+
google-api-python-client>=2.0.0
|
backend/test_smoke.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_gemini_connection():
|
| 8 |
+
from google import genai
|
| 9 |
+
|
| 10 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 11 |
+
assert api_key and api_key != "your_key_here", "GOOGLE_API_KEY not set in .env"
|
| 12 |
+
|
| 13 |
+
client = genai.Client(api_key=api_key)
|
| 14 |
+
|
| 15 |
+
MODEL_NAME = "gemini-2.5-flash"
|
| 16 |
+
|
| 17 |
+
response = client.models.generate_content(
|
| 18 |
+
model=MODEL_NAME,
|
| 19 |
+
contents="Reply with exactly: OK",
|
| 20 |
+
)
|
| 21 |
+
assert "OK" in response.text
|
| 22 |
+
print(f"Gemini OK (model={MODEL_NAME}): {response.text.strip()}")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_gemini_function_calling_api():
|
| 26 |
+
"""Verify the SDK supports the function calling API we need."""
|
| 27 |
+
from google.genai import types
|
| 28 |
+
|
| 29 |
+
assert hasattr(types.Part, "from_function_response"), (
|
| 30 |
+
"types.Part.from_function_response not found — SDK version may be incompatible. "
|
| 31 |
+
"Try: pip install --upgrade google-genai"
|
| 32 |
+
)
|
| 33 |
+
print("Gemini SDK function calling API: OK")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_huggingface_model():
|
| 37 |
+
import torch
|
| 38 |
+
from sentence_transformers import CrossEncoder
|
| 39 |
+
|
| 40 |
+
model = CrossEncoder(
|
| 41 |
+
"cross-encoder/ms-marco-MiniLM-L6-v2",
|
| 42 |
+
default_activation_function=torch.nn.Sigmoid(),
|
| 43 |
+
)
|
| 44 |
+
pairs = [("urgent action required deadline missed", "Le product owner attend les corrections avant lundi. C'est bloquant.")]
|
| 45 |
+
scores = model.predict(pairs)
|
| 46 |
+
score = float(scores[0])
|
| 47 |
+
assert 0.0 <= score <= 1.0
|
| 48 |
+
print(f"HuggingFace OK: score={score:.3f}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
test_gemini_connection()
|
| 53 |
+
test_gemini_function_calling_api()
|
| 54 |
+
test_huggingface_model()
|
| 55 |
+
print("\nAll smoke tests passed.")
|
backend/tools.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool functions exposed to Gemini. Uses real Google APIs when authenticated, mock data otherwise."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from google_auth import is_authenticated
|
| 7 |
+
from google_services import fetch_emails, fetch_events, fetch_doc_content, list_recent_docs, search_project_signals
|
| 8 |
+
from mock_data import MOCK_EMAILS, MOCK_EVENTS, MOCK_SEARCH
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _load_project_keywords(project_id: str) -> list[str]:
|
| 12 |
+
"""Load keywords for a project from config.json."""
|
| 13 |
+
config_path = Path(__file__).parent / "config.json"
|
| 14 |
+
with open(config_path) as f:
|
| 15 |
+
config = json.load(f)
|
| 16 |
+
for p in config.get("projects", []):
|
| 17 |
+
if p["id"] == project_id:
|
| 18 |
+
return p.get("keywords", [])
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def read_emails(project_id: str) -> str:
|
| 23 |
+
"""Read recent emails for a project. Uses Gmail API if authenticated, mock data otherwise."""
|
| 24 |
+
if is_authenticated():
|
| 25 |
+
keywords = _load_project_keywords(project_id)
|
| 26 |
+
emails = fetch_emails(project_id, keywords=keywords)
|
| 27 |
+
if not emails:
|
| 28 |
+
return f"No emails found for project {project_id}."
|
| 29 |
+
lines = []
|
| 30 |
+
for e in emails:
|
| 31 |
+
days = e.get("days_since_reply")
|
| 32 |
+
silence = f"{days} days ago" if days is not None else "unknown"
|
| 33 |
+
lines.append(
|
| 34 |
+
f"From: {e['from']}\n"
|
| 35 |
+
f"Subject: {e['subject']}\n"
|
| 36 |
+
f"Body: {e['body']}\n"
|
| 37 |
+
f"Last reply: {silence}"
|
| 38 |
+
)
|
| 39 |
+
return "\n---\n".join(lines)
|
| 40 |
+
|
| 41 |
+
# Fallback to mock data
|
| 42 |
+
emails = MOCK_EMAILS.get(project_id, [])
|
| 43 |
+
if not emails:
|
| 44 |
+
return f"No emails found for project {project_id}."
|
| 45 |
+
lines = []
|
| 46 |
+
for e in emails:
|
| 47 |
+
silence = f"{e['days_since_reply']} days ago" if e["days_since_reply"] is not None else "N/A (newsletter)"
|
| 48 |
+
lines.append(
|
| 49 |
+
f"From: {e['from']}\n"
|
| 50 |
+
f"Subject: {e['subject']}\n"
|
| 51 |
+
f"Body: {e['body']}\n"
|
| 52 |
+
f"Last reply: {silence}"
|
| 53 |
+
)
|
| 54 |
+
return "\n---\n".join(lines)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_events(project_id: str) -> str:
|
| 58 |
+
"""Get upcoming calendar events for a project. Uses Calendar API if authenticated, mock data otherwise."""
|
| 59 |
+
if is_authenticated():
|
| 60 |
+
keywords = _load_project_keywords(project_id)
|
| 61 |
+
events = fetch_events(project_id, keywords=keywords)
|
| 62 |
+
if not events:
|
| 63 |
+
return f"No upcoming events for project {project_id}."
|
| 64 |
+
lines = []
|
| 65 |
+
for ev in events:
|
| 66 |
+
prep = "YES" if ev["prep_block"] else "NO"
|
| 67 |
+
lines.append(f"{ev['title']} at {ev['time']} (prep block: {prep})")
|
| 68 |
+
return "\n".join(lines)
|
| 69 |
+
|
| 70 |
+
# Fallback to mock data
|
| 71 |
+
events = MOCK_EVENTS.get(project_id, [])
|
| 72 |
+
if not events:
|
| 73 |
+
return f"No upcoming events for project {project_id}."
|
| 74 |
+
lines = []
|
| 75 |
+
for ev in events:
|
| 76 |
+
prep = "YES" if ev["prep_block"] else "NO"
|
| 77 |
+
lines.append(f"{ev['title']} at {ev['time']} (prep block: {prep})")
|
| 78 |
+
return "\n".join(lines)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def search_web(project_id: str) -> str:
|
| 82 |
+
"""Search for recent external signals relevant to a project. Uses Gemini + Google Search grounding when possible."""
|
| 83 |
+
keywords = _load_project_keywords(project_id)
|
| 84 |
+
# Load project name from config
|
| 85 |
+
config_path = Path(__file__).parent / "config.json"
|
| 86 |
+
with open(config_path) as f:
|
| 87 |
+
config = json.load(f)
|
| 88 |
+
project_name = project_id
|
| 89 |
+
for p in config.get("projects", []):
|
| 90 |
+
if p["id"] == project_id:
|
| 91 |
+
project_name = p.get("name", project_id)
|
| 92 |
+
break
|
| 93 |
+
|
| 94 |
+
result = search_project_signals(project_name, keywords=keywords)
|
| 95 |
+
if result:
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
# Fallback to mock data
|
| 99 |
+
return MOCK_SEARCH.get(project_id, "No relevant external signals found.")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def read_doc(doc_id: str) -> str:
|
| 103 |
+
"""Read a Google Doc by its document ID. Returns title and content."""
|
| 104 |
+
if not is_authenticated():
|
| 105 |
+
return "Not connected to Google. Please authenticate first."
|
| 106 |
+
|
| 107 |
+
result = fetch_doc_content(doc_id)
|
| 108 |
+
if not result["content"]:
|
| 109 |
+
return f"Could not read document {doc_id}."
|
| 110 |
+
|
| 111 |
+
return f"Title: {result['title']}\n\nContent:\n{result['content']}"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def list_docs() -> str:
|
| 115 |
+
"""List recently modified Google Docs."""
|
| 116 |
+
if not is_authenticated():
|
| 117 |
+
return "Not connected to Google. Please authenticate first."
|
| 118 |
+
|
| 119 |
+
docs = list_recent_docs(max_results=10)
|
| 120 |
+
if not docs:
|
| 121 |
+
return "No recent Google Docs found."
|
| 122 |
+
|
| 123 |
+
lines = []
|
| 124 |
+
for d in docs:
|
| 125 |
+
lines.append(f"- {d['title']} (ID: {d['id']}, modified: {d['modified_time']})")
|
| 126 |
+
return "\n".join(lines)
|
backend/tts.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import struct
|
| 4 |
+
|
| 5 |
+
from google import genai
|
| 6 |
+
from google.genai import types
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _make_wav(pcm_data: bytes, sample_rate: int = 24000, channels: int = 1, bits_per_sample: int = 16) -> bytes:
|
| 10 |
+
"""Wrap raw PCM bytes in a WAV container."""
|
| 11 |
+
data_size = len(pcm_data)
|
| 12 |
+
byte_rate = sample_rate * channels * bits_per_sample // 8
|
| 13 |
+
block_align = channels * bits_per_sample // 8
|
| 14 |
+
|
| 15 |
+
buf = io.BytesIO()
|
| 16 |
+
# RIFF header
|
| 17 |
+
buf.write(b"RIFF")
|
| 18 |
+
buf.write(struct.pack("<I", 36 + data_size))
|
| 19 |
+
buf.write(b"WAVE")
|
| 20 |
+
# fmt chunk
|
| 21 |
+
buf.write(b"fmt ")
|
| 22 |
+
buf.write(struct.pack("<I", 16)) # chunk size
|
| 23 |
+
buf.write(struct.pack("<H", 1)) # PCM format
|
| 24 |
+
buf.write(struct.pack("<H", channels))
|
| 25 |
+
buf.write(struct.pack("<I", sample_rate))
|
| 26 |
+
buf.write(struct.pack("<I", byte_rate))
|
| 27 |
+
buf.write(struct.pack("<H", block_align))
|
| 28 |
+
buf.write(struct.pack("<H", bits_per_sample))
|
| 29 |
+
# data chunk
|
| 30 |
+
buf.write(b"data")
|
| 31 |
+
buf.write(struct.pack("<I", data_size))
|
| 32 |
+
buf.write(pcm_data)
|
| 33 |
+
return buf.getvalue()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_speech(text: str) -> bytes:
|
| 37 |
+
"""Generate speech audio from text using Gemini TTS.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
text: The text to convert to speech.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
WAV audio bytes ready to play in a browser.
|
| 44 |
+
"""
|
| 45 |
+
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
| 46 |
+
|
| 47 |
+
response = client.models.generate_content(
|
| 48 |
+
model="gemini-2.5-flash-preview-tts",
|
| 49 |
+
contents=text,
|
| 50 |
+
config=types.GenerateContentConfig(
|
| 51 |
+
response_modalities=["AUDIO"],
|
| 52 |
+
speech_config=types.SpeechConfig(
|
| 53 |
+
voice_config=types.VoiceConfig(
|
| 54 |
+
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
| 55 |
+
voice_name="Kore",
|
| 56 |
+
)
|
| 57 |
+
),
|
| 58 |
+
),
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
audio_data = response.candidates[0].content.parts[0].inline_data.data
|
| 63 |
+
return _make_wav(audio_data)
|
backend/urgency.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_URGENCY_KEYWORDS = [
|
| 2 |
+
"urgent", "bloquant", "blocking", "deadline", "critique", "critical",
|
| 3 |
+
"avant lundi", "avant mardi", "avant mercredi", "asap", "immediatement",
|
| 4 |
+
"pas de reponse", "no reply", "en attente", "waiting", "overdue",
|
| 5 |
+
"sprint review", "livrable", "retard", "action item",
|
| 6 |
+
]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def load_model():
|
| 10 |
+
"""No-op — kept for API compatibility."""
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def score_urgency(email_text: str) -> float:
|
| 15 |
+
"""Score how urgent an email is (0-1) using keyword matching."""
|
| 16 |
+
text_lower = email_text.lower()
|
| 17 |
+
keyword_hits = sum(1 for kw in _URGENCY_KEYWORDS if kw in text_lower)
|
| 18 |
+
return round(min(keyword_hits / 3.0, 1.0), 3)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def score_emails(emails: list[dict]) -> list[dict]:
|
| 22 |
+
"""Score a list of email dicts. Adds 'urgency_score' key to each."""
|
| 23 |
+
for email in emails:
|
| 24 |
+
email["urgency_score"] = score_urgency(email["body"])
|
| 25 |
+
return emails
|
docs/superpowers/plans/2026-03-14-operator-plan.md
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Operator Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Build an autonomous AI agent that monitors 3 projects for a work-study student and delivers proactive briefs — unprompted.
|
| 6 |
+
|
| 7 |
+
**Architecture:** FastAPI backend with Gemini 3 Flash function calling loop, HuggingFace urgency scoring, deterministic initiative engine, SSE streaming to a React frontend with dark terminal aesthetic and TTS voice output.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python (FastAPI, google-genai, sentence-transformers), React (Vite on Replit), SSE, Web Speech API
|
| 10 |
+
|
| 11 |
+
**Spec:** `docs/superpowers/specs/2026-03-14-operator-design.md`
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## File Structure
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
backend/
|
| 19 |
+
├── config.json # 3 projects (school, company, startup)
|
| 20 |
+
├── mock_data.py # hardcoded emails, events, search results
|
| 21 |
+
├── tools.py # 3 Gemini-callable functions
|
| 22 |
+
├── urgency.py # HuggingFace cross-encoder scorer
|
| 23 |
+
├── initiative.py # 4 deterministic alert rules
|
| 24 |
+
├── agent.py # Gemini function calling loop + on_event callback
|
| 25 |
+
├── main.py # FastAPI + SSE endpoint
|
| 26 |
+
├── requirements.txt # Python deps
|
| 27 |
+
├── .env # API keys (not committed)
|
| 28 |
+
└── test_smoke.py # Smoke tests for critical paths
|
| 29 |
+
|
| 30 |
+
frontend/ (React on Replit)
|
| 31 |
+
├── src/
|
| 32 |
+
│ ├── App.jsx
|
| 33 |
+
│ ├── components/
|
| 34 |
+
│ │ ├── Header.jsx
|
| 35 |
+
│ │ ├── ProjectCard.jsx
|
| 36 |
+
│ │ ├── ToolFeed.jsx
|
| 37 |
+
│ │ └── BriefPanel.jsx
|
| 38 |
+
│ ├── hooks/
|
| 39 |
+
│ │ └── useOperatorSSE.js
|
| 40 |
+
│ └── index.css
|
| 41 |
+
├── index.html
|
| 42 |
+
└── package.json
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Chunk 1: Phase 1 — Fondations
|
| 48 |
+
|
| 49 |
+
### Task 1: Init backend project
|
| 50 |
+
|
| 51 |
+
**Files:**
|
| 52 |
+
- Create: `backend/requirements.txt`
|
| 53 |
+
- Create: `backend/.env`
|
| 54 |
+
- Create: `backend/config.json`
|
| 55 |
+
|
| 56 |
+
- [ ] **Step 1: Create `backend/requirements.txt`**
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
google-genai>=1.0.0
|
| 60 |
+
sentence-transformers>=3.0.0
|
| 61 |
+
fastapi>=0.115.0
|
| 62 |
+
uvicorn>=0.30.0
|
| 63 |
+
sse-starlette>=2.0.0
|
| 64 |
+
python-dotenv>=1.0.0
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
- [ ] **Step 2: Create `backend/.env`**
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
GOOGLE_API_KEY=your_key_here
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
- [ ] **Step 3: Create `backend/config.json`**
|
| 74 |
+
|
| 75 |
+
```json
|
| 76 |
+
{
|
| 77 |
+
"mode": "demo",
|
| 78 |
+
"projects": [
|
| 79 |
+
{
|
| 80 |
+
"id": "school",
|
| 81 |
+
"name": "Master IA \u2014 Sorbonne",
|
| 82 |
+
"contact": "prof.martinez@sorbonne.fr",
|
| 83 |
+
"deadline": "2026-03-18T23:59:00",
|
| 84 |
+
"color": "#00FF88",
|
| 85 |
+
"keywords": ["cours", "rendu", "memoire", "soutenance", "TP"]
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "company",
|
| 89 |
+
"name": "Alternance \u2014 BNP Paribas",
|
| 90 |
+
"contact": "sophie.renard@bnpparibas.com",
|
| 91 |
+
"deadline": "2026-03-17T09:00:00",
|
| 92 |
+
"color": "#FF4444",
|
| 93 |
+
"keywords": ["sprint", "daily", "jira", "livrable", "prod"]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "startup",
|
| 97 |
+
"name": "Side Project \u2014 NoctaAI",
|
| 98 |
+
"contact": "yassine@noctaai.com",
|
| 99 |
+
"deadline": "2026-04-05T00:00:00",
|
| 100 |
+
"color": "#FF8800",
|
| 101 |
+
"keywords": ["NoctaAI", "MVP", "landing", "beta", "funding"]
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
}
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
- [ ] **Step 4: Install dependencies**
|
| 108 |
+
|
| 109 |
+
Run: `cd backend && pip install -r requirements.txt`
|
| 110 |
+
Expected: All packages install. `torch` CPU-only pulled by sentence-transformers (~2min).
|
| 111 |
+
|
| 112 |
+
- [ ] **Step 5: Commit**
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
git init
|
| 116 |
+
echo ".env" > .gitignore
|
| 117 |
+
echo "__pycache__/" >> .gitignore
|
| 118 |
+
echo "*.pyc" >> .gitignore
|
| 119 |
+
git add backend/requirements.txt backend/config.json .gitignore
|
| 120 |
+
git commit -m "init: backend project with deps and config"
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
### Task 2: Mock data
|
| 126 |
+
|
| 127 |
+
**Files:**
|
| 128 |
+
- Create: `backend/mock_data.py`
|
| 129 |
+
|
| 130 |
+
- [ ] **Step 1: Create `backend/mock_data.py`**
|
| 131 |
+
|
| 132 |
+
```python
|
| 133 |
+
MOCK_EMAILS = {
|
| 134 |
+
"school": [
|
| 135 |
+
{
|
| 136 |
+
"from": "prof.martinez@sorbonne.fr",
|
| 137 |
+
"subject": "Rendu TP Deep Learning",
|
| 138 |
+
"body": "Le rendu du TP sur les transformers est pour mercredi 18 mars 23h59. Format notebook + rapport PDF. Pas d'extension possible.",
|
| 139 |
+
"date": "2026-03-12",
|
| 140 |
+
"days_since_reply": 0,
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"from": "admin-master@sorbonne.fr",
|
| 144 |
+
"subject": "Soutenance memoire - date fixee",
|
| 145 |
+
"body": "Votre soutenance est fixee au 15 avril. Merci de confirmer votre sujet avant le 25 mars.",
|
| 146 |
+
"date": "2026-03-13",
|
| 147 |
+
"days_since_reply": 1,
|
| 148 |
+
},
|
| 149 |
+
],
|
| 150 |
+
"company": [
|
| 151 |
+
{
|
| 152 |
+
"from": "sophie.renard@bnpparibas.com",
|
| 153 |
+
"subject": "Re: Dashboard analytics - feedback",
|
| 154 |
+
"body": "Le product owner attend les corrections sur le dashboard avant lundi matin. Les KPIs ne remontent pas correctement en prod. C'est bloquant pour la review sprint.",
|
| 155 |
+
"date": "2026-03-08",
|
| 156 |
+
"days_since_reply": 6,
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"from": "tech-lead@bnpparibas.com",
|
| 160 |
+
"subject": "Daily standup notes",
|
| 161 |
+
"body": "Action item pour toi : fixer le bug sur le filtre date du dashboard. Sprint review mardi.",
|
| 162 |
+
"date": "2026-03-13",
|
| 163 |
+
"days_since_reply": 1,
|
| 164 |
+
},
|
| 165 |
+
],
|
| 166 |
+
"startup": [
|
| 167 |
+
{
|
| 168 |
+
"from": "yassine@noctaai.com",
|
| 169 |
+
"subject": "Re: Landing page v2",
|
| 170 |
+
"body": "La landing est live mais le taux de conversion est a 0.8%. On doit refaire le hero. Tu peux t'en occuper ce weekend ?",
|
| 171 |
+
"date": "2026-03-11",
|
| 172 |
+
"days_since_reply": 3,
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"from": "newsletter@techcrunch.com",
|
| 176 |
+
"subject": "Y Combinator ouvre les candidatures W26",
|
| 177 |
+
"body": "YC Winter 2026 applications are now open. Deadline: April 10. Focus on AI-native startups.",
|
| 178 |
+
"date": "2026-03-14",
|
| 179 |
+
"days_since_reply": None,
|
| 180 |
+
},
|
| 181 |
+
],
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
MOCK_EVENTS = {
|
| 185 |
+
"school": [
|
| 186 |
+
{"title": "TP Deep Learning - rendu", "time": "2026-03-18T23:59", "prep_block": False},
|
| 187 |
+
{"title": "Cours NLP avance", "time": "2026-03-15T09:00", "prep_block": True},
|
| 188 |
+
],
|
| 189 |
+
"company": [
|
| 190 |
+
{"title": "Sprint Review", "time": "2026-03-17T10:00", "prep_block": False},
|
| 191 |
+
{"title": "Daily Standup", "time": "2026-03-14T09:30", "prep_block": True},
|
| 192 |
+
],
|
| 193 |
+
"startup": [],
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
MOCK_SEARCH = {
|
| 197 |
+
"school": "Sorbonne Universite - les inscriptions au Master IA 2026 battent des records, +40% de candidatures.",
|
| 198 |
+
"company": "BNP Paribas lance un nouveau programme d'acceleration data & IA pour ses alternants.",
|
| 199 |
+
"startup": "Y Combinator ouvre les candidatures Winter 2026. Deadline 10 avril. Focus AI-native startups.",
|
| 200 |
+
}
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
- [ ] **Step 2: Verify import works**
|
| 204 |
+
|
| 205 |
+
Run: `cd backend && python -c "from mock_data import MOCK_EMAILS; print(len(MOCK_EMAILS['company']))"`
|
| 206 |
+
Expected: `2`
|
| 207 |
+
|
| 208 |
+
- [ ] **Step 3: Commit**
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
git add backend/mock_data.py
|
| 212 |
+
git commit -m "feat: add mock data for 3 demo projects"
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
### Task 3: Smoke test Gemini API
|
| 218 |
+
|
| 219 |
+
**Files:**
|
| 220 |
+
- Create: `backend/test_smoke.py`
|
| 221 |
+
|
| 222 |
+
- [ ] **Step 1: Create smoke test file**
|
| 223 |
+
|
| 224 |
+
```python
|
| 225 |
+
import os
|
| 226 |
+
from dotenv import load_dotenv
|
| 227 |
+
|
| 228 |
+
load_dotenv()
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_gemini_connection():
|
| 232 |
+
from google import genai
|
| 233 |
+
|
| 234 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 235 |
+
assert api_key and api_key != "your_key_here", "GOOGLE_API_KEY not set in .env"
|
| 236 |
+
|
| 237 |
+
client = genai.Client(api_key=api_key)
|
| 238 |
+
|
| 239 |
+
# Verify available models — use gemini-2.0-flash (Gemini 3 Flash may have a different ID)
|
| 240 |
+
# If the hackathon provides a different model name, update MODEL_NAME here
|
| 241 |
+
MODEL_NAME = "gemini-2.0-flash"
|
| 242 |
+
|
| 243 |
+
response = client.models.generate_content(
|
| 244 |
+
model=MODEL_NAME,
|
| 245 |
+
contents="Reply with exactly: OK",
|
| 246 |
+
)
|
| 247 |
+
assert "OK" in response.text
|
| 248 |
+
print(f"Gemini OK (model={MODEL_NAME}): {response.text.strip()}")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_gemini_function_calling_api():
|
| 252 |
+
"""Verify the SDK supports the function calling API we need."""
|
| 253 |
+
from google.genai import types
|
| 254 |
+
|
| 255 |
+
# Verify Part.from_function_response exists
|
| 256 |
+
assert hasattr(types.Part, "from_function_response"), (
|
| 257 |
+
"types.Part.from_function_response not found — SDK version may be incompatible. "
|
| 258 |
+
"Try: pip install --upgrade google-genai"
|
| 259 |
+
)
|
| 260 |
+
print("Gemini SDK function calling API: OK")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_huggingface_model():
|
| 264 |
+
import torch
|
| 265 |
+
from sentence_transformers import CrossEncoder
|
| 266 |
+
|
| 267 |
+
model = CrossEncoder(
|
| 268 |
+
"cross-encoder/ms-marco-MiniLM-L6-v2",
|
| 269 |
+
default_activation_function=torch.nn.Sigmoid(),
|
| 270 |
+
)
|
| 271 |
+
pairs = [("urgent action required deadline missed", "Le product owner attend les corrections avant lundi. C'est bloquant.")]
|
| 272 |
+
scores = model.predict(pairs)
|
| 273 |
+
score = float(scores[0])
|
| 274 |
+
assert 0.0 <= score <= 1.0
|
| 275 |
+
print(f"HuggingFace OK: score={score:.3f}")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
test_gemini_connection()
|
| 280 |
+
test_gemini_function_calling_api()
|
| 281 |
+
test_huggingface_model()
|
| 282 |
+
print("All smoke tests passed.")
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
- [ ] **Step 2: Run smoke tests**
|
| 286 |
+
|
| 287 |
+
Run: `cd backend && python test_smoke.py`
|
| 288 |
+
Expected: Both tests pass, prints scores. First HuggingFace run downloads model (~80MB).
|
| 289 |
+
|
| 290 |
+
- [ ] **Step 3: Commit**
|
| 291 |
+
|
| 292 |
+
```bash
|
| 293 |
+
git add backend/test_smoke.py
|
| 294 |
+
git commit -m "test: smoke tests for Gemini and HuggingFace"
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
---
|
| 298 |
+
|
| 299 |
+
## Chunk 2: Phase 2 — Backend
|
| 300 |
+
|
| 301 |
+
### Task 4: Tools (Gemini-callable functions)
|
| 302 |
+
|
| 303 |
+
**Files:**
|
| 304 |
+
- Create: `backend/tools.py`
|
| 305 |
+
|
| 306 |
+
- [ ] **Step 1: Create `backend/tools.py`**
|
| 307 |
+
|
| 308 |
+
```python
|
| 309 |
+
import json
|
| 310 |
+
from mock_data import MOCK_EMAILS, MOCK_EVENTS, MOCK_SEARCH
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def read_emails(project_id: str) -> str:
|
| 314 |
+
"""Read recent emails for a project. Returns sender, subject, body, and days since last reply."""
|
| 315 |
+
emails = MOCK_EMAILS.get(project_id, [])
|
| 316 |
+
if not emails:
|
| 317 |
+
return f"No emails found for project {project_id}."
|
| 318 |
+
lines = []
|
| 319 |
+
for e in emails:
|
| 320 |
+
silence = f"{e['days_since_reply']} days ago" if e["days_since_reply"] is not None else "N/A (newsletter)"
|
| 321 |
+
lines.append(
|
| 322 |
+
f"From: {e['from']}\n"
|
| 323 |
+
f"Subject: {e['subject']}\n"
|
| 324 |
+
f"Body: {e['body']}\n"
|
| 325 |
+
f"Last reply: {silence}\n"
|
| 326 |
+
)
|
| 327 |
+
return "\n---\n".join(lines)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def get_events(project_id: str) -> str:
|
| 331 |
+
"""Get upcoming calendar events for a project. Shows title, time, and whether a prep block exists."""
|
| 332 |
+
events = MOCK_EVENTS.get(project_id, [])
|
| 333 |
+
if not events:
|
| 334 |
+
return f"No upcoming events for project {project_id}."
|
| 335 |
+
lines = []
|
| 336 |
+
for ev in events:
|
| 337 |
+
prep = "YES" if ev["prep_block"] else "NO"
|
| 338 |
+
lines.append(f"{ev['title']} at {ev['time']} (prep block: {prep})")
|
| 339 |
+
return "\n".join(lines)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def search_web(project_id: str) -> str:
|
| 343 |
+
"""Search for recent external signals relevant to a project (news, funding, announcements)."""
|
| 344 |
+
result = MOCK_SEARCH.get(project_id, "No relevant external signals found.")
|
| 345 |
+
return result
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
- [ ] **Step 2: Test tools manually**
|
| 349 |
+
|
| 350 |
+
Run: `cd backend && python -c "from tools import read_emails; print(read_emails('company'))"`
|
| 351 |
+
Expected: Prints BNP Paribas emails formatted.
|
| 352 |
+
|
| 353 |
+
- [ ] **Step 3: Commit**
|
| 354 |
+
|
| 355 |
+
```bash
|
| 356 |
+
git add backend/tools.py
|
| 357 |
+
git commit -m "feat: 3 Gemini-callable tool functions with mock data"
|
| 358 |
+
```
|
| 359 |
+
|
| 360 |
+
---
|
| 361 |
+
|
| 362 |
+
### Task 5: Urgency scorer
|
| 363 |
+
|
| 364 |
+
**Files:**
|
| 365 |
+
- Create: `backend/urgency.py`
|
| 366 |
+
|
| 367 |
+
- [ ] **Step 1: Create `backend/urgency.py`**
|
| 368 |
+
|
| 369 |
+
```python
|
| 370 |
+
import torch
|
| 371 |
+
from sentence_transformers import CrossEncoder
|
| 372 |
+
|
| 373 |
+
_URGENCY_QUERY = "urgent action required deadline missed no reply blocked critical"
|
| 374 |
+
|
| 375 |
+
_model = None
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _get_model():
|
| 379 |
+
global _model
|
| 380 |
+
if _model is None:
|
| 381 |
+
_model = CrossEncoder(
|
| 382 |
+
"cross-encoder/ms-marco-MiniLM-L6-v2",
|
| 383 |
+
default_activation_function=torch.nn.Sigmoid(),
|
| 384 |
+
)
|
| 385 |
+
return _model
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def load_model():
|
| 389 |
+
"""Call at startup to pre-load the model."""
|
| 390 |
+
_get_model()
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def score_urgency(email_text: str) -> float:
|
| 394 |
+
"""Score how urgent an email is (0-1). Higher = more urgent."""
|
| 395 |
+
model = _get_model()
|
| 396 |
+
pairs = [(_URGENCY_QUERY, email_text)]
|
| 397 |
+
scores = model.predict(pairs)
|
| 398 |
+
return round(float(scores[0]), 3)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def score_emails(emails: list[dict]) -> list[dict]:
|
| 402 |
+
"""Score a list of email dicts. Adds 'urgency_score' key to each."""
|
| 403 |
+
for email in emails:
|
| 404 |
+
email["urgency_score"] = score_urgency(email["body"])
|
| 405 |
+
return emails
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
- [ ] **Step 2: Test urgency scoring**
|
| 409 |
+
|
| 410 |
+
Run: `cd backend && python -c "from urgency import score_urgency; print(score_urgency('Le product owner attend les corrections. C est bloquant pour la sprint review.'))" `
|
| 411 |
+
Expected: A float between 0 and 1 (likely > 0.5 for this urgent text).
|
| 412 |
+
|
| 413 |
+
- [ ] **Step 3: Commit**
|
| 414 |
+
|
| 415 |
+
```bash
|
| 416 |
+
git add backend/urgency.py
|
| 417 |
+
git commit -m "feat: HuggingFace urgency scorer with sigmoid activation"
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
---
|
| 421 |
+
|
| 422 |
+
### Task 6: Initiative engine
|
| 423 |
+
|
| 424 |
+
**Files:**
|
| 425 |
+
- Create: `backend/initiative.py`
|
| 426 |
+
|
| 427 |
+
- [ ] **Step 1: Create `backend/initiative.py`**
|
| 428 |
+
|
| 429 |
+
```python
|
| 430 |
+
from datetime import datetime, timezone
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def evaluate_project(project: dict, emails: list[dict], events: list[dict], search_score: float) -> dict:
|
| 434 |
+
"""Evaluate a project's status based on deterministic rules.
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
{"status": "READY"|"URGENT"|"SIGNAL", "alerts": [str]}
|
| 438 |
+
"""
|
| 439 |
+
alerts = []
|
| 440 |
+
status = "READY"
|
| 441 |
+
|
| 442 |
+
# Rule 1: Silence detected
|
| 443 |
+
reply_days = [
|
| 444 |
+
e["days_since_reply"]
|
| 445 |
+
for e in emails
|
| 446 |
+
if e.get("days_since_reply") is not None
|
| 447 |
+
]
|
| 448 |
+
if reply_days:
|
| 449 |
+
max_silence = max(reply_days)
|
| 450 |
+
if max_silence >= 5:
|
| 451 |
+
contact = emails[0]["from"]
|
| 452 |
+
alerts.append(f"Silence depuis {max_silence} jours de {contact}")
|
| 453 |
+
status = "URGENT"
|
| 454 |
+
|
| 455 |
+
# Rule 2: Deadline < 48h without prep block
|
| 456 |
+
deadline = datetime.fromisoformat(project["deadline"])
|
| 457 |
+
now = datetime.now()
|
| 458 |
+
hours_to_deadline = (deadline - now).total_seconds() / 3600
|
| 459 |
+
has_prep = any(e.get("prep_block", False) for e in events)
|
| 460 |
+
if hours_to_deadline < 48 and not has_prep:
|
| 461 |
+
alerts.append(f"Deadline dans {int(hours_to_deadline)}h - aucun bloc de prep")
|
| 462 |
+
status = "URGENT"
|
| 463 |
+
|
| 464 |
+
# Rule 3: External signal relevant
|
| 465 |
+
if search_score > 0.6:
|
| 466 |
+
alerts.append("Signal externe pertinent detecte")
|
| 467 |
+
if status != "URGENT":
|
| 468 |
+
status = "SIGNAL"
|
| 469 |
+
|
| 470 |
+
# Rule 4: High urgency email
|
| 471 |
+
urgency_scores = [e.get("urgency_score", 0) for e in emails]
|
| 472 |
+
if any(s > 0.8 for s in urgency_scores):
|
| 473 |
+
alerts.append("Email a haute urgence detecte")
|
| 474 |
+
status = "URGENT"
|
| 475 |
+
|
| 476 |
+
return {"status": status, "alerts": alerts}
|
| 477 |
+
```
|
| 478 |
+
|
| 479 |
+
- [ ] **Step 2: Test initiative engine**
|
| 480 |
+
|
| 481 |
+
Run: `cd backend && python -c "
|
| 482 |
+
from initiative import evaluate_project
|
| 483 |
+
result = evaluate_project(
|
| 484 |
+
{'deadline': '2026-03-17T09:00:00'},
|
| 485 |
+
[{'from': 'sophie@bnp.com', 'days_since_reply': 6, 'urgency_score': 0.85, 'body': 'test'}],
|
| 486 |
+
[{'prep_block': False}],
|
| 487 |
+
0.3
|
| 488 |
+
)
|
| 489 |
+
print(result)
|
| 490 |
+
"`
|
| 491 |
+
Expected: `{'status': 'URGENT', 'alerts': ['Silence depuis 6 jours de sophie@bnp.com', ...]}`
|
| 492 |
+
|
| 493 |
+
- [ ] **Step 3: Commit**
|
| 494 |
+
|
| 495 |
+
```bash
|
| 496 |
+
git add backend/initiative.py
|
| 497 |
+
git commit -m "feat: deterministic initiative engine with 4 alert rules"
|
| 498 |
+
```
|
| 499 |
+
|
| 500 |
+
---
|
| 501 |
+
|
| 502 |
+
### Task 7: Agent loop (Gemini function calling)
|
| 503 |
+
|
| 504 |
+
**Files:**
|
| 505 |
+
- Create: `backend/agent.py`
|
| 506 |
+
|
| 507 |
+
- [ ] **Step 1: Create `backend/agent.py`**
|
| 508 |
+
|
| 509 |
+
```python
|
| 510 |
+
import asyncio
|
| 511 |
+
import json
|
| 512 |
+
import os
|
| 513 |
+
import time
|
| 514 |
+
from typing import AsyncGenerator, Callable
|
| 515 |
+
|
| 516 |
+
from dotenv import load_dotenv
|
| 517 |
+
from google import genai
|
| 518 |
+
from google.genai import types
|
| 519 |
+
|
| 520 |
+
from initiative import evaluate_project
|
| 521 |
+
from mock_data import MOCK_EMAILS, MOCK_EVENTS
|
| 522 |
+
from tools import get_events, read_emails, search_web
|
| 523 |
+
from urgency import score_emails, score_urgency
|
| 524 |
+
|
| 525 |
+
load_dotenv()
|
| 526 |
+
|
| 527 |
+
SYSTEM_PROMPT = """Tu es Operator - un agent autonome de classe Jarvis pour les professionnels occupes.
|
| 528 |
+
Tu monitores plusieurs projets actifs simultanement.
|
| 529 |
+
Tu parles en premier. Tu n'attends pas qu'on te demande.
|
| 530 |
+
|
| 531 |
+
Au demarrage :
|
| 532 |
+
1. Charge tous les projets actifs du contexte
|
| 533 |
+
2. Scanne les emails, le calendrier et le web pour chaque projet
|
| 534 |
+
3. Evalue l'urgence avec les signaux fournis
|
| 535 |
+
4. Delivre un brief proactif multi-projet - sans qu'on te le demande
|
| 536 |
+
|
| 537 |
+
Regles :
|
| 538 |
+
- Ne demande jamais de clarification. Fais des hypotheses raisonnables.
|
| 539 |
+
- N'explique jamais ce que tu fais. Fais-le.
|
| 540 |
+
- 5 bullets maximum par projet. Brutalement concis.
|
| 541 |
+
- Si un outil echoue, saute-le et note le manque.
|
| 542 |
+
- Chaque projet a son propre bloc. Ne melange jamais les contextes.
|
| 543 |
+
- Commence par le projet le plus urgent.
|
| 544 |
+
- L'humain est occupe. Chaque mot doit meriter sa place.
|
| 545 |
+
- Parle en francais.
|
| 546 |
+
"""
|
| 547 |
+
|
| 548 |
+
TOOL_FUNCTIONS = {
|
| 549 |
+
"read_emails": read_emails,
|
| 550 |
+
"get_events": get_events,
|
| 551 |
+
"search_web": search_web,
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
TOOL_DECLARATIONS = [
|
| 555 |
+
types.Tool(function_declarations=[
|
| 556 |
+
types.FunctionDeclaration(
|
| 557 |
+
name="read_emails",
|
| 558 |
+
description="Lit les emails recents pour un projet. Retourne expediteur, sujet, corps, jours depuis derniere reponse.",
|
| 559 |
+
parameters=types.Schema(
|
| 560 |
+
type="OBJECT",
|
| 561 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 562 |
+
required=["project_id"],
|
| 563 |
+
),
|
| 564 |
+
),
|
| 565 |
+
types.FunctionDeclaration(
|
| 566 |
+
name="get_events",
|
| 567 |
+
description="Recupere les evenements calendrier a venir pour un projet. Indique titre, heure, et si un bloc de preparation existe.",
|
| 568 |
+
parameters=types.Schema(
|
| 569 |
+
type="OBJECT",
|
| 570 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 571 |
+
required=["project_id"],
|
| 572 |
+
),
|
| 573 |
+
),
|
| 574 |
+
types.FunctionDeclaration(
|
| 575 |
+
name="search_web",
|
| 576 |
+
description="Recherche des signaux externes pertinents pour un projet (news, funding, annonces).",
|
| 577 |
+
parameters=types.Schema(
|
| 578 |
+
type="OBJECT",
|
| 579 |
+
properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
|
| 580 |
+
required=["project_id"],
|
| 581 |
+
),
|
| 582 |
+
),
|
| 583 |
+
])
|
| 584 |
+
]
|
| 585 |
+
|
| 586 |
+
MAX_ITERATIONS = 15
|
| 587 |
+
TIMEOUT_SECONDS = 30
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
async def run_operator(projects: list[dict], on_event: Callable) -> str:
|
| 591 |
+
"""Run the Operator agent loop.
|
| 592 |
+
|
| 593 |
+
Args:
|
| 594 |
+
projects: list of project dicts from config.json
|
| 595 |
+
on_event: callback(event_dict) called for each step
|
| 596 |
+
|
| 597 |
+
Returns:
|
| 598 |
+
The final brief text.
|
| 599 |
+
"""
|
| 600 |
+
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
| 601 |
+
|
| 602 |
+
project_summary = json.dumps(
|
| 603 |
+
[{"id": p["id"], "name": p["name"], "contact": p["contact"], "deadline": p["deadline"]}
|
| 604 |
+
for p in projects],
|
| 605 |
+
ensure_ascii=False,
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
chat = client.chats.create(
|
| 609 |
+
model="gemini-2.0-flash",
|
| 610 |
+
config=types.GenerateContentConfig(
|
| 611 |
+
system_instruction=SYSTEM_PROMPT,
|
| 612 |
+
tools=TOOL_DECLARATIONS,
|
| 613 |
+
),
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
user_msg = (
|
| 617 |
+
f"Voici tes 3 projets actifs :\n{project_summary}\n\n"
|
| 618 |
+
"Commence par scanner chaque projet (emails, calendrier, web), "
|
| 619 |
+
"puis delivre ton brief proactif."
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
response = chat.send_message(user_msg)
|
| 623 |
+
iterations = 0
|
| 624 |
+
start_time = time.time()
|
| 625 |
+
collected_data = {} # project_id -> {emails, events, search}
|
| 626 |
+
|
| 627 |
+
while iterations < MAX_ITERATIONS and (time.time() - start_time) < TIMEOUT_SECONDS:
|
| 628 |
+
# Check if there are function calls
|
| 629 |
+
function_calls = []
|
| 630 |
+
for part in response.candidates[0].content.parts:
|
| 631 |
+
if part.function_call:
|
| 632 |
+
function_calls.append(part.function_call)
|
| 633 |
+
|
| 634 |
+
if not function_calls:
|
| 635 |
+
break # No more tool calls, Gemini is done
|
| 636 |
+
|
| 637 |
+
# Execute each function call
|
| 638 |
+
function_responses = []
|
| 639 |
+
for fc in function_calls:
|
| 640 |
+
tool_name = fc.name
|
| 641 |
+
tool_args = dict(fc.args) if fc.args else {}
|
| 642 |
+
|
| 643 |
+
await on_event({
|
| 644 |
+
"type": "tool_call",
|
| 645 |
+
"tool": tool_name,
|
| 646 |
+
"project": tool_args.get("project_id", ""),
|
| 647 |
+
"status": "running",
|
| 648 |
+
})
|
| 649 |
+
|
| 650 |
+
# Execute the tool
|
| 651 |
+
tool_fn = TOOL_FUNCTIONS.get(tool_name)
|
| 652 |
+
if tool_fn:
|
| 653 |
+
try:
|
| 654 |
+
result = tool_fn(**tool_args)
|
| 655 |
+
except Exception as e:
|
| 656 |
+
result = f"Error: {str(e)}"
|
| 657 |
+
else:
|
| 658 |
+
result = f"Unknown tool: {tool_name}"
|
| 659 |
+
|
| 660 |
+
# Track collected data
|
| 661 |
+
pid = tool_args.get("project_id", "")
|
| 662 |
+
if pid not in collected_data:
|
| 663 |
+
collected_data[pid] = {}
|
| 664 |
+
collected_data[pid][tool_name] = result
|
| 665 |
+
|
| 666 |
+
await on_event({
|
| 667 |
+
"type": "tool_result",
|
| 668 |
+
"tool": tool_name,
|
| 669 |
+
"project": pid,
|
| 670 |
+
"result": result[:200],
|
| 671 |
+
"status": "done",
|
| 672 |
+
})
|
| 673 |
+
|
| 674 |
+
function_responses.append(
|
| 675 |
+
types.Part.from_function_response(
|
| 676 |
+
name=tool_name,
|
| 677 |
+
response={"result": result},
|
| 678 |
+
)
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
iterations += 1
|
| 682 |
+
|
| 683 |
+
# Send function results back to Gemini
|
| 684 |
+
response = chat.send_message(function_responses)
|
| 685 |
+
|
| 686 |
+
# --- Deterministic scoring phase (single pass, cached) ---
|
| 687 |
+
evaluations = {}
|
| 688 |
+
for pid, data in collected_data.items():
|
| 689 |
+
emails = MOCK_EMAILS.get(pid, [])
|
| 690 |
+
scored_emails = score_emails([dict(e) for e in emails])
|
| 691 |
+
|
| 692 |
+
for e in scored_emails:
|
| 693 |
+
await on_event({
|
| 694 |
+
"type": "urgency",
|
| 695 |
+
"project": pid,
|
| 696 |
+
"email_subject": e["subject"],
|
| 697 |
+
"score": e["urgency_score"],
|
| 698 |
+
})
|
| 699 |
+
|
| 700 |
+
project = next((p for p in projects if p["id"] == pid), {})
|
| 701 |
+
events = MOCK_EVENTS.get(pid, [])
|
| 702 |
+
search_score = score_urgency(data.get("search_web", ""))
|
| 703 |
+
|
| 704 |
+
evaluation = evaluate_project(project, scored_emails, events, search_score)
|
| 705 |
+
evaluations[pid] = {"evaluation": evaluation, "project": project}
|
| 706 |
+
|
| 707 |
+
await on_event({
|
| 708 |
+
"type": "initiative",
|
| 709 |
+
"project": pid,
|
| 710 |
+
"status": evaluation["status"],
|
| 711 |
+
"alerts": evaluation["alerts"],
|
| 712 |
+
})
|
| 713 |
+
|
| 714 |
+
# --- Final brief with initiative context (reuse cached evaluations) ---
|
| 715 |
+
initiative_summary = ""
|
| 716 |
+
for pid, cached in evaluations.items():
|
| 717 |
+
evaluation = cached["evaluation"]
|
| 718 |
+
project = cached["project"]
|
| 719 |
+
initiative_summary += (
|
| 720 |
+
f"\n[{project.get('name', pid)}] Status: {evaluation['status']}\n"
|
| 721 |
+
f"Alertes: {'; '.join(evaluation['alerts']) if evaluation['alerts'] else 'aucune'}\n"
|
| 722 |
+
)
|
| 723 |
+
|
| 724 |
+
brief_prompt = (
|
| 725 |
+
f"Voici les evaluations d'urgence de tes 3 projets :\n{initiative_summary}\n\n"
|
| 726 |
+
"Genere maintenant ton brief proactif final. Commence par le plus urgent. "
|
| 727 |
+
"5 bullets max par projet. Sois brutalement concis. Parle en francais."
|
| 728 |
+
)
|
| 729 |
+
|
| 730 |
+
brief_response = chat.send_message(brief_prompt)
|
| 731 |
+
brief_text = brief_response.text
|
| 732 |
+
|
| 733 |
+
await on_event({
|
| 734 |
+
"type": "brief",
|
| 735 |
+
"text": brief_text,
|
| 736 |
+
})
|
| 737 |
+
|
| 738 |
+
return brief_text
|
| 739 |
+
```
|
| 740 |
+
|
| 741 |
+
- [ ] **Step 2: Test agent loop standalone**
|
| 742 |
+
|
| 743 |
+
Run: `cd backend && python -c "
|
| 744 |
+
import asyncio
|
| 745 |
+
from agent import run_operator
|
| 746 |
+
import json
|
| 747 |
+
|
| 748 |
+
with open('config.json') as f:
|
| 749 |
+
config = json.load(f)
|
| 750 |
+
|
| 751 |
+
async def print_event(e):
|
| 752 |
+
print(f' [{e[\"type\"]}] {e.get(\"tool\", e.get(\"project\", \"\"))} {e.get(\"status\", \"\")}')
|
| 753 |
+
|
| 754 |
+
async def main():
|
| 755 |
+
brief = await run_operator(config['projects'], print_event)
|
| 756 |
+
print('---BRIEF---')
|
| 757 |
+
print(brief)
|
| 758 |
+
|
| 759 |
+
asyncio.run(main())
|
| 760 |
+
"`
|
| 761 |
+
Expected: Tool calls printed step by step, then a French brief covering 3 projects. BNP Paribas should be first (most urgent).
|
| 762 |
+
|
| 763 |
+
- [ ] **Step 3: Commit**
|
| 764 |
+
|
| 765 |
+
```bash
|
| 766 |
+
git add backend/agent.py
|
| 767 |
+
git commit -m "feat: Gemini function calling agent loop with initiative engine"
|
| 768 |
+
```
|
| 769 |
+
|
| 770 |
+
---
|
| 771 |
+
|
| 772 |
+
### Task 8: FastAPI + SSE endpoint
|
| 773 |
+
|
| 774 |
+
**Files:**
|
| 775 |
+
- Create: `backend/main.py`
|
| 776 |
+
|
| 777 |
+
- [ ] **Step 1: Create `backend/main.py`**
|
| 778 |
+
|
| 779 |
+
```python
|
| 780 |
+
import asyncio
|
| 781 |
+
import json
|
| 782 |
+
import os
|
| 783 |
+
from contextlib import asynccontextmanager
|
| 784 |
+
|
| 785 |
+
from dotenv import load_dotenv
|
| 786 |
+
from fastapi import FastAPI
|
| 787 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 788 |
+
from sse_starlette.sse import EventSourceResponse
|
| 789 |
+
|
| 790 |
+
from agent import run_operator
|
| 791 |
+
from urgency import load_model
|
| 792 |
+
|
| 793 |
+
load_dotenv()
|
| 794 |
+
|
| 795 |
+
# Fail fast if API key is missing
|
| 796 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 797 |
+
if not api_key or api_key == "your_key_here":
|
| 798 |
+
raise RuntimeError("GOOGLE_API_KEY not set. Check backend/.env")
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
@asynccontextmanager
|
| 802 |
+
async def lifespan(app):
|
| 803 |
+
load_model()
|
| 804 |
+
print("HuggingFace model loaded.")
|
| 805 |
+
yield
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
app = FastAPI(title="Operator", lifespan=lifespan)
|
| 809 |
+
|
| 810 |
+
app.add_middleware(
|
| 811 |
+
CORSMiddleware,
|
| 812 |
+
allow_origins=["*"],
|
| 813 |
+
allow_credentials=True,
|
| 814 |
+
allow_methods=["*"],
|
| 815 |
+
allow_headers=["*"],
|
| 816 |
+
)
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def load_config():
|
| 820 |
+
with open("config.json") as f:
|
| 821 |
+
return json.load(f)
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
@app.get("/api/run")
|
| 825 |
+
async def run():
|
| 826 |
+
config = load_config()
|
| 827 |
+
queue = asyncio.Queue()
|
| 828 |
+
|
| 829 |
+
async def on_event(event: dict):
|
| 830 |
+
await queue.put(event)
|
| 831 |
+
|
| 832 |
+
async def generator():
|
| 833 |
+
task = asyncio.create_task(run_operator(config["projects"], on_event))
|
| 834 |
+
|
| 835 |
+
while True:
|
| 836 |
+
try:
|
| 837 |
+
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
| 838 |
+
yield {"event": event["type"], "data": json.dumps(event, ensure_ascii=False)}
|
| 839 |
+
if event["type"] == "brief":
|
| 840 |
+
break
|
| 841 |
+
except asyncio.TimeoutError:
|
| 842 |
+
if task.done():
|
| 843 |
+
break
|
| 844 |
+
continue
|
| 845 |
+
|
| 846 |
+
await task
|
| 847 |
+
|
| 848 |
+
return EventSourceResponse(generator())
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
@app.get("/api/health")
|
| 852 |
+
async def health():
|
| 853 |
+
return {"status": "ok"}
|
| 854 |
+
```
|
| 855 |
+
|
| 856 |
+
- [ ] **Step 2: Run the server**
|
| 857 |
+
|
| 858 |
+
Run: `cd backend && uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
|
| 859 |
+
Expected: Server starts, prints "HuggingFace model loaded."
|
| 860 |
+
|
| 861 |
+
- [ ] **Step 3: Test SSE endpoint**
|
| 862 |
+
|
| 863 |
+
Run (in another terminal): `curl -N http://localhost:8000/api/run`
|
| 864 |
+
Expected: Stream of SSE events (tool_call, tool_result, urgency, initiative, brief).
|
| 865 |
+
|
| 866 |
+
- [ ] **Step 4: Commit**
|
| 867 |
+
|
| 868 |
+
```bash
|
| 869 |
+
git add backend/main.py
|
| 870 |
+
git commit -m "feat: FastAPI server with SSE streaming endpoint"
|
| 871 |
+
```
|
| 872 |
+
|
| 873 |
+
---
|
| 874 |
+
|
| 875 |
+
## Chunk 3: Phase 2 — Frontend (parallel with Chunk 2)
|
| 876 |
+
|
| 877 |
+
### Task 9: React project setup on Replit
|
| 878 |
+
|
| 879 |
+
- [ ] **Step 1: Create React project on Replit**
|
| 880 |
+
|
| 881 |
+
Use the "React (Vite)" template on Replit. This gives you `react`, `react-dom`, `vite` pre-configured.
|
| 882 |
+
|
| 883 |
+
- [ ] **Step 2: Set up `index.css` — dark theme base**
|
| 884 |
+
|
| 885 |
+
Replace `src/index.css` with:
|
| 886 |
+
|
| 887 |
+
```css
|
| 888 |
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
| 889 |
+
|
| 890 |
+
* {
|
| 891 |
+
margin: 0;
|
| 892 |
+
padding: 0;
|
| 893 |
+
box-sizing: border-box;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
body {
|
| 897 |
+
background: #0a0a0a;
|
| 898 |
+
color: #e0e0e0;
|
| 899 |
+
font-family: 'JetBrains Mono', monospace;
|
| 900 |
+
min-height: 100vh;
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
:root {
|
| 904 |
+
--green: #00FF88;
|
| 905 |
+
--red: #FF4444;
|
| 906 |
+
--orange: #FF8800;
|
| 907 |
+
--bg: #0a0a0a;
|
| 908 |
+
--card-bg: #111111;
|
| 909 |
+
--border: #222222;
|
| 910 |
+
--text-dim: #666666;
|
| 911 |
+
}
|
| 912 |
+
```
|
| 913 |
+
|
| 914 |
+
- [ ] **Step 3: Commit**
|
| 915 |
+
|
| 916 |
+
---
|
| 917 |
+
|
| 918 |
+
### Task 10: Header component
|
| 919 |
+
|
| 920 |
+
**Files:**
|
| 921 |
+
- Create: `src/components/Header.jsx`
|
| 922 |
+
|
| 923 |
+
- [ ] **Step 1: Create `src/components/Header.jsx`**
|
| 924 |
+
|
| 925 |
+
```jsx
|
| 926 |
+
import { useState } from 'react';
|
| 927 |
+
|
| 928 |
+
export default function Header({ onScan, isScanning }) {
|
| 929 |
+
return (
|
| 930 |
+
<header style={{
|
| 931 |
+
display: 'flex',
|
| 932 |
+
justifyContent: 'space-between',
|
| 933 |
+
alignItems: 'center',
|
| 934 |
+
padding: '24px 32px',
|
| 935 |
+
borderBottom: '1px solid var(--border)',
|
| 936 |
+
}}>
|
| 937 |
+
<div>
|
| 938 |
+
<h1 style={{
|
| 939 |
+
fontSize: '28px',
|
| 940 |
+
fontWeight: 700,
|
| 941 |
+
color: 'var(--green)',
|
| 942 |
+
letterSpacing: '4px',
|
| 943 |
+
}}>
|
| 944 |
+
■ OPERATOR
|
| 945 |
+
</h1>
|
| 946 |
+
<p style={{
|
| 947 |
+
fontSize: '12px',
|
| 948 |
+
color: 'var(--text-dim)',
|
| 949 |
+
marginTop: '4px',
|
| 950 |
+
fontStyle: 'italic',
|
| 951 |
+
}}>
|
| 952 |
+
The AI Agent That Works While You Talk.
|
| 953 |
+
</p>
|
| 954 |
+
</div>
|
| 955 |
+
<button
|
| 956 |
+
onClick={onScan}
|
| 957 |
+
disabled={isScanning}
|
| 958 |
+
style={{
|
| 959 |
+
background: isScanning ? 'var(--border)' : 'var(--green)',
|
| 960 |
+
color: '#0a0a0a',
|
| 961 |
+
border: 'none',
|
| 962 |
+
padding: '12px 24px',
|
| 963 |
+
fontFamily: 'inherit',
|
| 964 |
+
fontSize: '14px',
|
| 965 |
+
fontWeight: 700,
|
| 966 |
+
cursor: isScanning ? 'not-allowed' : 'pointer',
|
| 967 |
+
letterSpacing: '2px',
|
| 968 |
+
}}
|
| 969 |
+
>
|
| 970 |
+
{isScanning ? 'SCANNING...' : 'LANCER LE SCAN'}
|
| 971 |
+
</button>
|
| 972 |
+
</header>
|
| 973 |
+
);
|
| 974 |
+
}
|
| 975 |
+
```
|
| 976 |
+
|
| 977 |
+
- [ ] **Step 2: Commit**
|
| 978 |
+
|
| 979 |
+
---
|
| 980 |
+
|
| 981 |
+
### Task 11: ProjectCard component
|
| 982 |
+
|
| 983 |
+
**Files:**
|
| 984 |
+
- Create: `src/components/ProjectCard.jsx`
|
| 985 |
+
|
| 986 |
+
- [ ] **Step 1: Create `src/components/ProjectCard.jsx`**
|
| 987 |
+
|
| 988 |
+
```jsx
|
| 989 |
+
const STATUS_CONFIG = {
|
| 990 |
+
STANDBY: { label: 'STANDBY', bg: '#222', color: '#666' },
|
| 991 |
+
READY: { label: 'READY', bg: '#00FF8833', color: '#00FF88' },
|
| 992 |
+
URGENT: { label: 'URGENT', bg: '#FF444433', color: '#FF4444' },
|
| 993 |
+
SIGNAL: { label: 'SIGNAL', bg: '#FF880033', color: '#FF8800' },
|
| 994 |
+
};
|
| 995 |
+
|
| 996 |
+
export default function ProjectCard({ project, status, alerts }) {
|
| 997 |
+
const st = STATUS_CONFIG[status] || STATUS_CONFIG.STANDBY;
|
| 998 |
+
|
| 999 |
+
return (
|
| 1000 |
+
<div style={{
|
| 1001 |
+
background: 'var(--card-bg)',
|
| 1002 |
+
borderLeft: `4px solid ${project.color}`,
|
| 1003 |
+
padding: '20px',
|
| 1004 |
+
flex: 1,
|
| 1005 |
+
minWidth: '250px',
|
| 1006 |
+
}}>
|
| 1007 |
+
<div style={{
|
| 1008 |
+
display: 'flex',
|
| 1009 |
+
justifyContent: 'space-between',
|
| 1010 |
+
alignItems: 'center',
|
| 1011 |
+
marginBottom: '12px',
|
| 1012 |
+
}}>
|
| 1013 |
+
<span style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px' }}>
|
| 1014 |
+
PROJECT
|
| 1015 |
+
</span>
|
| 1016 |
+
<span style={{
|
| 1017 |
+
fontSize: '11px',
|
| 1018 |
+
fontWeight: 700,
|
| 1019 |
+
padding: '4px 10px',
|
| 1020 |
+
background: st.bg,
|
| 1021 |
+
color: st.color,
|
| 1022 |
+
letterSpacing: '1px',
|
| 1023 |
+
animation: status === 'URGENT' ? 'pulse 1.5s infinite' : 'none',
|
| 1024 |
+
}}>
|
| 1025 |
+
{st.label}
|
| 1026 |
+
</span>
|
| 1027 |
+
</div>
|
| 1028 |
+
|
| 1029 |
+
<h3 style={{ fontSize: '16px', color: '#fff', marginBottom: '8px' }}>
|
| 1030 |
+
{project.name}
|
| 1031 |
+
</h3>
|
| 1032 |
+
|
| 1033 |
+
{project.contact && (
|
| 1034 |
+
<p style={{ fontSize: '12px', color: 'var(--text-dim)', marginBottom: '12px' }}>
|
| 1035 |
+
{project.contact}
|
| 1036 |
+
</p>
|
| 1037 |
+
)}
|
| 1038 |
+
|
| 1039 |
+
{alerts && alerts.length > 0 && (
|
| 1040 |
+
<ul style={{ listStyle: 'none', padding: 0 }}>
|
| 1041 |
+
{alerts.map((alert, i) => (
|
| 1042 |
+
<li key={i} style={{
|
| 1043 |
+
fontSize: '12px',
|
| 1044 |
+
color: st.color,
|
| 1045 |
+
padding: '4px 0',
|
| 1046 |
+
borderTop: '1px solid var(--border)',
|
| 1047 |
+
}}>
|
| 1048 |
+
{alert}
|
| 1049 |
+
</li>
|
| 1050 |
+
))}
|
| 1051 |
+
</ul>
|
| 1052 |
+
)}
|
| 1053 |
+
</div>
|
| 1054 |
+
);
|
| 1055 |
+
}
|
| 1056 |
+
```
|
| 1057 |
+
|
| 1058 |
+
- [ ] **Step 2: Add pulse animation to `index.css`**
|
| 1059 |
+
|
| 1060 |
+
Append to `src/index.css`:
|
| 1061 |
+
|
| 1062 |
+
```css
|
| 1063 |
+
@keyframes pulse {
|
| 1064 |
+
0%, 100% { opacity: 1; }
|
| 1065 |
+
50% { opacity: 0.5; }
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
@keyframes slideIn {
|
| 1069 |
+
from { transform: translateX(-20px); opacity: 0; }
|
| 1070 |
+
to { transform: translateX(0); opacity: 1; }
|
| 1071 |
+
}
|
| 1072 |
+
```
|
| 1073 |
+
|
| 1074 |
+
- [ ] **Step 3: Commit**
|
| 1075 |
+
|
| 1076 |
+
---
|
| 1077 |
+
|
| 1078 |
+
### Task 12: ToolFeed component
|
| 1079 |
+
|
| 1080 |
+
**Files:**
|
| 1081 |
+
- Create: `src/components/ToolFeed.jsx`
|
| 1082 |
+
|
| 1083 |
+
- [ ] **Step 1: Create `src/components/ToolFeed.jsx`**
|
| 1084 |
+
|
| 1085 |
+
```jsx
|
| 1086 |
+
const STATUS_ICONS = {
|
| 1087 |
+
running: '\u25CF', // filled circle
|
| 1088 |
+
done: '\u2713', // checkmark
|
| 1089 |
+
waiting: '\u25CB', // empty circle
|
| 1090 |
+
};
|
| 1091 |
+
|
| 1092 |
+
const PROJECT_COLORS = {
|
| 1093 |
+
school: '#00FF88',
|
| 1094 |
+
company: '#FF4444',
|
| 1095 |
+
startup: '#FF8800',
|
| 1096 |
+
};
|
| 1097 |
+
|
| 1098 |
+
export default function ToolFeed({ toolCalls }) {
|
| 1099 |
+
if (!toolCalls || toolCalls.length === 0) {
|
| 1100 |
+
return (
|
| 1101 |
+
<div style={{
|
| 1102 |
+
background: 'var(--card-bg)',
|
| 1103 |
+
padding: '20px',
|
| 1104 |
+
borderTop: '1px solid var(--border)',
|
| 1105 |
+
}}>
|
| 1106 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '8px' }}>
|
| 1107 |
+
TOOL FEED
|
| 1108 |
+
</h4>
|
| 1109 |
+
<p style={{ fontSize: '12px', color: 'var(--text-dim)' }}>
|
| 1110 |
+
En attente du scan...
|
| 1111 |
+
</p>
|
| 1112 |
+
</div>
|
| 1113 |
+
);
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
return (
|
| 1117 |
+
<div style={{
|
| 1118 |
+
background: 'var(--card-bg)',
|
| 1119 |
+
padding: '20px',
|
| 1120 |
+
borderTop: '1px solid var(--border)',
|
| 1121 |
+
maxHeight: '200px',
|
| 1122 |
+
overflowY: 'auto',
|
| 1123 |
+
}}>
|
| 1124 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '12px' }}>
|
| 1125 |
+
TOOL FEED
|
| 1126 |
+
</h4>
|
| 1127 |
+
{toolCalls.map((tc, i) => (
|
| 1128 |
+
<div
|
| 1129 |
+
key={i}
|
| 1130 |
+
style={{
|
| 1131 |
+
display: 'flex',
|
| 1132 |
+
alignItems: 'center',
|
| 1133 |
+
gap: '12px',
|
| 1134 |
+
padding: '6px 0',
|
| 1135 |
+
fontSize: '13px',
|
| 1136 |
+
animation: 'slideIn 0.3s ease-out',
|
| 1137 |
+
animationDelay: `${i * 0.1}s`,
|
| 1138 |
+
animationFillMode: 'both',
|
| 1139 |
+
}}
|
| 1140 |
+
>
|
| 1141 |
+
<span style={{ color: tc.status === 'done' ? '#00FF88' : tc.status === 'running' ? '#FF8800' : '#666' }}>
|
| 1142 |
+
{STATUS_ICONS[tc.status] || STATUS_ICONS.waiting}
|
| 1143 |
+
</span>
|
| 1144 |
+
<span style={{ color: '#fff', minWidth: '140px' }}>{tc.tool}({tc.project})</span>
|
| 1145 |
+
<div style={{
|
| 1146 |
+
flex: 1,
|
| 1147 |
+
height: '4px',
|
| 1148 |
+
background: '#222',
|
| 1149 |
+
borderRadius: '2px',
|
| 1150 |
+
overflow: 'hidden',
|
| 1151 |
+
}}>
|
| 1152 |
+
<div style={{
|
| 1153 |
+
width: tc.status === 'done' ? '100%' : tc.status === 'running' ? '60%' : '0%',
|
| 1154 |
+
height: '100%',
|
| 1155 |
+
background: PROJECT_COLORS[tc.project] || 'var(--green)',
|
| 1156 |
+
transition: 'width 0.5s ease',
|
| 1157 |
+
}} />
|
| 1158 |
+
</div>
|
| 1159 |
+
<span style={{ fontSize: '11px', color: 'var(--text-dim)', minWidth: '40px', textAlign: 'right' }}>
|
| 1160 |
+
{tc.status === 'done' ? 'done' : tc.status === 'running' ? '...' : 'wait'}
|
| 1161 |
+
</span>
|
| 1162 |
+
</div>
|
| 1163 |
+
))}
|
| 1164 |
+
</div>
|
| 1165 |
+
);
|
| 1166 |
+
}
|
| 1167 |
+
```
|
| 1168 |
+
|
| 1169 |
+
- [ ] **Step 2: Commit**
|
| 1170 |
+
|
| 1171 |
+
---
|
| 1172 |
+
|
| 1173 |
+
### Task 13: BriefPanel component
|
| 1174 |
+
|
| 1175 |
+
**Files:**
|
| 1176 |
+
- Create: `src/components/BriefPanel.jsx`
|
| 1177 |
+
|
| 1178 |
+
- [ ] **Step 1: Create `src/components/BriefPanel.jsx`**
|
| 1179 |
+
|
| 1180 |
+
```jsx
|
| 1181 |
+
import { useState, useEffect } from 'react';
|
| 1182 |
+
|
| 1183 |
+
export default function BriefPanel({ briefText }) {
|
| 1184 |
+
const [displayedText, setDisplayedText] = useState('');
|
| 1185 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 1186 |
+
|
| 1187 |
+
// Typewriter effect
|
| 1188 |
+
useEffect(() => {
|
| 1189 |
+
if (!briefText) {
|
| 1190 |
+
setDisplayedText('');
|
| 1191 |
+
return;
|
| 1192 |
+
}
|
| 1193 |
+
setDisplayedText('');
|
| 1194 |
+
let i = 0;
|
| 1195 |
+
const interval = setInterval(() => {
|
| 1196 |
+
if (i < briefText.length) {
|
| 1197 |
+
setDisplayedText(briefText.slice(0, i + 1));
|
| 1198 |
+
i++;
|
| 1199 |
+
} else {
|
| 1200 |
+
clearInterval(interval);
|
| 1201 |
+
}
|
| 1202 |
+
}, 15);
|
| 1203 |
+
return () => clearInterval(interval);
|
| 1204 |
+
}, [briefText]);
|
| 1205 |
+
|
| 1206 |
+
const handleSpeak = () => {
|
| 1207 |
+
if (isPlaying) {
|
| 1208 |
+
speechSynthesis.cancel();
|
| 1209 |
+
setIsPlaying(false);
|
| 1210 |
+
return;
|
| 1211 |
+
}
|
| 1212 |
+
const utterance = new SpeechSynthesisUtterance(briefText);
|
| 1213 |
+
utterance.lang = 'fr-FR';
|
| 1214 |
+
utterance.rate = 1.1;
|
| 1215 |
+
utterance.pitch = 0.9;
|
| 1216 |
+
utterance.onend = () => setIsPlaying(false);
|
| 1217 |
+
speechSynthesis.speak(utterance);
|
| 1218 |
+
setIsPlaying(true);
|
| 1219 |
+
};
|
| 1220 |
+
|
| 1221 |
+
if (!briefText) {
|
| 1222 |
+
return null;
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
return (
|
| 1226 |
+
<div style={{
|
| 1227 |
+
background: 'var(--card-bg)',
|
| 1228 |
+
padding: '24px',
|
| 1229 |
+
borderTop: '1px solid var(--border)',
|
| 1230 |
+
}}>
|
| 1231 |
+
<div style={{
|
| 1232 |
+
display: 'flex',
|
| 1233 |
+
justifyContent: 'space-between',
|
| 1234 |
+
alignItems: 'center',
|
| 1235 |
+
marginBottom: '16px',
|
| 1236 |
+
}}>
|
| 1237 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px' }}>
|
| 1238 |
+
BRIEF
|
| 1239 |
+
</h4>
|
| 1240 |
+
<button
|
| 1241 |
+
onClick={handleSpeak}
|
| 1242 |
+
style={{
|
| 1243 |
+
background: 'none',
|
| 1244 |
+
border: '1px solid var(--green)',
|
| 1245 |
+
color: 'var(--green)',
|
| 1246 |
+
padding: '6px 16px',
|
| 1247 |
+
fontFamily: 'inherit',
|
| 1248 |
+
fontSize: '12px',
|
| 1249 |
+
cursor: 'pointer',
|
| 1250 |
+
}}
|
| 1251 |
+
>
|
| 1252 |
+
{isPlaying ? '\u23F9 Stop' : '\uD83D\uDD0A Play'}
|
| 1253 |
+
</button>
|
| 1254 |
+
</div>
|
| 1255 |
+
<div style={{
|
| 1256 |
+
fontSize: '14px',
|
| 1257 |
+
lineHeight: '1.6',
|
| 1258 |
+
whiteSpace: 'pre-wrap',
|
| 1259 |
+
color: '#e0e0e0',
|
| 1260 |
+
}}>
|
| 1261 |
+
{displayedText}
|
| 1262 |
+
{displayedText.length < (briefText?.length || 0) && (
|
| 1263 |
+
<span style={{ animation: 'pulse 0.8s infinite', color: 'var(--green)' }}>|</span>
|
| 1264 |
+
)}
|
| 1265 |
+
</div>
|
| 1266 |
+
</div>
|
| 1267 |
+
);
|
| 1268 |
+
}
|
| 1269 |
+
```
|
| 1270 |
+
|
| 1271 |
+
- [ ] **Step 2: Commit**
|
| 1272 |
+
|
| 1273 |
+
---
|
| 1274 |
+
|
| 1275 |
+
## Chunk 4: Phase 3 — Integration
|
| 1276 |
+
|
| 1277 |
+
### Task 14: SSE hook
|
| 1278 |
+
|
| 1279 |
+
**Files:**
|
| 1280 |
+
- Create: `src/hooks/useOperatorSSE.js`
|
| 1281 |
+
|
| 1282 |
+
- [ ] **Step 1: Create `src/hooks/useOperatorSSE.js`**
|
| 1283 |
+
|
| 1284 |
+
```jsx
|
| 1285 |
+
import { useState, useCallback } from 'react';
|
| 1286 |
+
|
| 1287 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
| 1288 |
+
|
| 1289 |
+
export default function useOperatorSSE() {
|
| 1290 |
+
const [toolCalls, setToolCalls] = useState([]);
|
| 1291 |
+
const [projectStatuses, setProjectStatuses] = useState({});
|
| 1292 |
+
const [briefText, setBriefText] = useState(null);
|
| 1293 |
+
const [isScanning, setIsScanning] = useState(false);
|
| 1294 |
+
|
| 1295 |
+
const startScan = useCallback(() => {
|
| 1296 |
+
setToolCalls([]);
|
| 1297 |
+
setProjectStatuses({});
|
| 1298 |
+
setBriefText(null);
|
| 1299 |
+
setIsScanning(true);
|
| 1300 |
+
|
| 1301 |
+
const source = new EventSource(`${BACKEND_URL}/api/run`);
|
| 1302 |
+
|
| 1303 |
+
source.addEventListener('tool_call', (e) => {
|
| 1304 |
+
const data = JSON.parse(e.data);
|
| 1305 |
+
setToolCalls(prev => [...prev, { tool: data.tool, project: data.project, status: 'running' }]);
|
| 1306 |
+
});
|
| 1307 |
+
|
| 1308 |
+
source.addEventListener('tool_result', (e) => {
|
| 1309 |
+
const data = JSON.parse(e.data);
|
| 1310 |
+
setToolCalls(prev =>
|
| 1311 |
+
prev.map(tc =>
|
| 1312 |
+
tc.tool === data.tool && tc.project === data.project && tc.status === 'running'
|
| 1313 |
+
? { ...tc, status: 'done' }
|
| 1314 |
+
: tc
|
| 1315 |
+
)
|
| 1316 |
+
);
|
| 1317 |
+
});
|
| 1318 |
+
|
| 1319 |
+
source.addEventListener('urgency', (e) => {
|
| 1320 |
+
const data = JSON.parse(e.data);
|
| 1321 |
+
// Urgency events update project info but don't set status yet
|
| 1322 |
+
});
|
| 1323 |
+
|
| 1324 |
+
source.addEventListener('initiative', (e) => {
|
| 1325 |
+
const data = JSON.parse(e.data);
|
| 1326 |
+
setProjectStatuses(prev => ({
|
| 1327 |
+
...prev,
|
| 1328 |
+
[data.project]: { status: data.status, alerts: data.alerts },
|
| 1329 |
+
}));
|
| 1330 |
+
});
|
| 1331 |
+
|
| 1332 |
+
source.addEventListener('brief', (e) => {
|
| 1333 |
+
const data = JSON.parse(e.data);
|
| 1334 |
+
setBriefText(data.text);
|
| 1335 |
+
setIsScanning(false);
|
| 1336 |
+
source.close();
|
| 1337 |
+
});
|
| 1338 |
+
|
| 1339 |
+
source.onerror = () => {
|
| 1340 |
+
setIsScanning(false);
|
| 1341 |
+
source.close();
|
| 1342 |
+
};
|
| 1343 |
+
}, []);
|
| 1344 |
+
|
| 1345 |
+
return { toolCalls, projectStatuses, briefText, isScanning, startScan };
|
| 1346 |
+
}
|
| 1347 |
+
```
|
| 1348 |
+
|
| 1349 |
+
- [ ] **Step 2: Commit**
|
| 1350 |
+
|
| 1351 |
+
---
|
| 1352 |
+
|
| 1353 |
+
### Task 15: App.jsx — assemble everything
|
| 1354 |
+
|
| 1355 |
+
**Files:**
|
| 1356 |
+
- Modify: `src/App.jsx`
|
| 1357 |
+
|
| 1358 |
+
- [ ] **Step 1: Replace `src/App.jsx`**
|
| 1359 |
+
|
| 1360 |
+
```jsx
|
| 1361 |
+
import Header from './components/Header';
|
| 1362 |
+
import ProjectCard from './components/ProjectCard';
|
| 1363 |
+
import ToolFeed from './components/ToolFeed';
|
| 1364 |
+
import BriefPanel from './components/BriefPanel';
|
| 1365 |
+
import useOperatorSSE from './hooks/useOperatorSSE';
|
| 1366 |
+
|
| 1367 |
+
const PROJECTS = [
|
| 1368 |
+
{ id: 'school', name: 'Master IA \u2014 Sorbonne', contact: 'prof.martinez@sorbonne.fr', color: '#00FF88' },
|
| 1369 |
+
{ id: 'company', name: 'Alternance \u2014 BNP Paribas', contact: 'sophie.renard@bnpparibas.com', color: '#FF4444' },
|
| 1370 |
+
{ id: 'startup', name: 'Side Project \u2014 NoctaAI', contact: 'yassine@noctaai.com', color: '#FF8800' },
|
| 1371 |
+
];
|
| 1372 |
+
|
| 1373 |
+
export default function App() {
|
| 1374 |
+
const { toolCalls, projectStatuses, briefText, isScanning, startScan } = useOperatorSSE();
|
| 1375 |
+
|
| 1376 |
+
return (
|
| 1377 |
+
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
| 1378 |
+
<Header onScan={startScan} isScanning={isScanning} />
|
| 1379 |
+
|
| 1380 |
+
<div style={{
|
| 1381 |
+
display: 'flex',
|
| 1382 |
+
gap: '16px',
|
| 1383 |
+
padding: '24px 32px',
|
| 1384 |
+
}}>
|
| 1385 |
+
{PROJECTS.map(project => {
|
| 1386 |
+
const ps = projectStatuses[project.id];
|
| 1387 |
+
return (
|
| 1388 |
+
<ProjectCard
|
| 1389 |
+
key={project.id}
|
| 1390 |
+
project={project}
|
| 1391 |
+
status={ps?.status || 'STANDBY'}
|
| 1392 |
+
alerts={ps?.alerts || []}
|
| 1393 |
+
/>
|
| 1394 |
+
);
|
| 1395 |
+
})}
|
| 1396 |
+
</div>
|
| 1397 |
+
|
| 1398 |
+
<div style={{ padding: '0 32px' }}>
|
| 1399 |
+
<ToolFeed toolCalls={toolCalls} />
|
| 1400 |
+
<BriefPanel briefText={briefText} />
|
| 1401 |
+
</div>
|
| 1402 |
+
</div>
|
| 1403 |
+
);
|
| 1404 |
+
}
|
| 1405 |
+
```
|
| 1406 |
+
|
| 1407 |
+
- [ ] **Step 2: Set env variable on Replit**
|
| 1408 |
+
|
| 1409 |
+
Add to Replit Secrets or `.env`: `VITE_BACKEND_URL=http://localhost:8000` (or your backend URL).
|
| 1410 |
+
|
| 1411 |
+
- [ ] **Step 3: Run frontend + backend together**
|
| 1412 |
+
|
| 1413 |
+
Backend: `cd backend && uvicorn main:app --host 0.0.0.0 --port 8000`
|
| 1414 |
+
Frontend: Replit auto-runs on `npm run dev`
|
| 1415 |
+
|
| 1416 |
+
- [ ] **Step 4: Click "LANCER LE SCAN" and verify full flow**
|
| 1417 |
+
|
| 1418 |
+
Expected:
|
| 1419 |
+
1. Cards start as STANDBY (grey)
|
| 1420 |
+
2. Tool feed animates step by step
|
| 1421 |
+
3. Cards update to READY/URGENT/SIGNAL with alerts
|
| 1422 |
+
4. Brief appears with typewriter effect
|
| 1423 |
+
5. Play button reads brief aloud in French
|
| 1424 |
+
|
| 1425 |
+
- [ ] **Step 5: Commit**
|
| 1426 |
+
|
| 1427 |
+
```bash
|
| 1428 |
+
git add src/
|
| 1429 |
+
git commit -m "feat: full integration — SSE hook + App assembly"
|
| 1430 |
+
```
|
| 1431 |
+
|
| 1432 |
+
---
|
| 1433 |
+
|
| 1434 |
+
## Chunk 5: Phase 4 — Polish & Demo
|
| 1435 |
+
|
| 1436 |
+
### Task 16: Auto-run mode for demo
|
| 1437 |
+
|
| 1438 |
+
**Files:**
|
| 1439 |
+
- Modify: `src/App.jsx`
|
| 1440 |
+
|
| 1441 |
+
- [ ] **Step 1: Add auto-scan on load**
|
| 1442 |
+
|
| 1443 |
+
In `App.jsx`, add a `useEffect` to auto-trigger the scan after a 2-second delay:
|
| 1444 |
+
|
| 1445 |
+
```jsx
|
| 1446 |
+
import { useEffect } from 'react';
|
| 1447 |
+
|
| 1448 |
+
// Inside App component, add:
|
| 1449 |
+
useEffect(() => {
|
| 1450 |
+
const params = new URLSearchParams(window.location.search);
|
| 1451 |
+
if (params.get('demo') === 'true') {
|
| 1452 |
+
const timer = setTimeout(() => startScan(), 2000);
|
| 1453 |
+
return () => clearTimeout(timer);
|
| 1454 |
+
}
|
| 1455 |
+
}, [startScan]);
|
| 1456 |
+
```
|
| 1457 |
+
|
| 1458 |
+
Access with `?demo=true` in the URL to auto-start. Without the param, manual button works normally.
|
| 1459 |
+
|
| 1460 |
+
- [ ] **Step 2: Commit**
|
| 1461 |
+
|
| 1462 |
+
```bash
|
| 1463 |
+
git commit -am "feat: auto-scan demo mode via ?demo=true URL param"
|
| 1464 |
+
```
|
| 1465 |
+
|
| 1466 |
+
---
|
| 1467 |
+
|
| 1468 |
+
### Task 17: System prompt tuning
|
| 1469 |
+
|
| 1470 |
+
- [ ] **Step 1: Test 3 variations of the system prompt**
|
| 1471 |
+
|
| 1472 |
+
In `backend/agent.py`, try these variations and pick the one that produces the best 90-second brief:
|
| 1473 |
+
|
| 1474 |
+
**Variation A (current):** General Operator persona in French.
|
| 1475 |
+
|
| 1476 |
+
**Variation B:** Add explicit structure format:
|
| 1477 |
+
```
|
| 1478 |
+
Format de sortie obligatoire :
|
| 1479 |
+
## [NOM DU PROJET] — [STATUS]
|
| 1480 |
+
- bullet 1
|
| 1481 |
+
- bullet 2
|
| 1482 |
+
(max 5 bullets)
|
| 1483 |
+
Termine par une question : "Quel projet veux-tu traiter en premier ?"
|
| 1484 |
+
```
|
| 1485 |
+
|
| 1486 |
+
**Variation C:** Add the demo story context explicitly:
|
| 1487 |
+
```
|
| 1488 |
+
Context: tu monitores un etudiant en alternance. Il a 3 projets :
|
| 1489 |
+
un master IA, une alternance en entreprise, et une startup qu'il monte.
|
| 1490 |
+
Tu dois l'alerter sur ce qui est urgent MAINTENANT.
|
| 1491 |
+
```
|
| 1492 |
+
|
| 1493 |
+
- [ ] **Step 2: Pick the best variation and update `agent.py`**
|
| 1494 |
+
|
| 1495 |
+
- [ ] **Step 3: Commit**
|
| 1496 |
+
|
| 1497 |
+
```bash
|
| 1498 |
+
git commit -am "tune: system prompt optimized for demo"
|
| 1499 |
+
```
|
| 1500 |
+
|
| 1501 |
+
---
|
| 1502 |
+
|
| 1503 |
+
### Task 18: Demo rehearsal checklist
|
| 1504 |
+
|
| 1505 |
+
- [ ] **Step 1: Run 5 full end-to-end demos**
|
| 1506 |
+
|
| 1507 |
+
For each run, check:
|
| 1508 |
+
- [ ] Operator speaks first (brief appears without user prompt)
|
| 1509 |
+
- [ ] BNP Paribas is first (most urgent)
|
| 1510 |
+
- [ ] All 3 projects covered
|
| 1511 |
+
- [ ] Tool feed animates correctly
|
| 1512 |
+
- [ ] TTS voice works in French
|
| 1513 |
+
- [ ] Total time < 90 seconds
|
| 1514 |
+
- [ ] No errors in console
|
| 1515 |
+
|
| 1516 |
+
- [ ] **Step 2: Fix any issues found**
|
| 1517 |
+
|
| 1518 |
+
- [ ] **Step 3: Final commit**
|
| 1519 |
+
|
| 1520 |
+
```bash
|
| 1521 |
+
git commit -am "polish: demo ready"
|
| 1522 |
+
```
|
docs/superpowers/specs/2026-03-14-operator-design.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Operator — Design Spec
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-03-14
|
| 4 |
+
**Context:** Gemini 3 Paris Hackathon — 7 hours — 2 people
|
| 5 |
+
**Stack:** Gemini 3 Flash + FastAPI + React (Replit) + HuggingFace
|
| 6 |
+
|
| 7 |
+
## 1. Problem
|
| 8 |
+
|
| 9 |
+
Professionals (here: a student in work-study) juggle 3+ active projects across email, calendar, and the web. No tool monitors all projects simultaneously and speaks first.
|
| 10 |
+
|
| 11 |
+
## 2. Solution
|
| 12 |
+
|
| 13 |
+
Operator is an autonomous agent that:
|
| 14 |
+
- Loads 3 projects from config
|
| 15 |
+
- Scans emails, calendar, and web per project (mocked for demo)
|
| 16 |
+
- Scores urgency via HuggingFace cross-encoder
|
| 17 |
+
- Applies deterministic initiative rules (silence, deadline, external signal)
|
| 18 |
+
- Delivers a proactive multi-project brief — unprompted — via text + voice
|
| 19 |
+
|
| 20 |
+
## 3. Demo Persona
|
| 21 |
+
|
| 22 |
+
An alternance student with 3 active projects:
|
| 23 |
+
|
| 24 |
+
| Project | Context | Expected Status |
|
| 25 |
+
|---------|---------|-----------------|
|
| 26 |
+
| Master IA — Sorbonne | TP deadline in 4 days, soutenance to confirm | GREEN / READY |
|
| 27 |
+
| Alternance — BNP Paribas | Manager silent 6 days, blocking bug, sprint review Monday, no prep block | RED / URGENT |
|
| 28 |
+
| Side Project — NoctaAI | YC W26 applications opened this morning | ORANGE / SIGNAL |
|
| 29 |
+
|
| 30 |
+
## 4. Architecture
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
Frontend (React / Replit)
|
| 34 |
+
│
|
| 35 |
+
│ SSE (Server-Sent Events)
|
| 36 |
+
│
|
| 37 |
+
Backend (FastAPI / local or Replit)
|
| 38 |
+
├── config.json → 3 projects
|
| 39 |
+
├── mock_data.py → simulated emails, events, search
|
| 40 |
+
├── tools.py → 3 functions declared to Gemini
|
| 41 |
+
├── agent.py → Gemini function calling loop
|
| 42 |
+
├── urgency.py → HuggingFace cross-encoder
|
| 43 |
+
├── initiative.py → deterministic alert rules
|
| 44 |
+
├── tts.py → TTS fallback (Google Cloud)
|
| 45 |
+
└── main.py → FastAPI endpoints
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### Data flow
|
| 49 |
+
|
| 50 |
+
1. Frontend calls `POST /api/run`
|
| 51 |
+
2. Backend loads `config.json` → 3 projects
|
| 52 |
+
3. For each project, Gemini calls tools via function calling
|
| 53 |
+
4. Each tool call is streamed to frontend via SSE (animated feed)
|
| 54 |
+
5. HuggingFace scores email urgency
|
| 55 |
+
6. Initiative engine evaluates: silence, deadline, external signal
|
| 56 |
+
7. All results + alerts injected into Gemini context
|
| 57 |
+
8. Gemini generates the proactive brief
|
| 58 |
+
9. Brief streamed to frontend + read aloud via TTS
|
| 59 |
+
|
| 60 |
+
## 5. Data Model
|
| 61 |
+
|
| 62 |
+
### config.json
|
| 63 |
+
|
| 64 |
+
```json
|
| 65 |
+
{
|
| 66 |
+
"mode": "demo",
|
| 67 |
+
"projects": [
|
| 68 |
+
{
|
| 69 |
+
"id": "school",
|
| 70 |
+
"name": "Master IA — Sorbonne",
|
| 71 |
+
"contact": "prof.martinez@sorbonne.fr",
|
| 72 |
+
"deadline": "2026-03-18T23:59:00",
|
| 73 |
+
"color": "#00FF88",
|
| 74 |
+
"keywords": ["cours", "rendu", "memoire", "soutenance", "TP"]
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "company",
|
| 78 |
+
"name": "Alternance — BNP Paribas",
|
| 79 |
+
"contact": "sophie.renard@bnpparibas.com",
|
| 80 |
+
"deadline": "2026-03-17T09:00:00",
|
| 81 |
+
"color": "#FF4444",
|
| 82 |
+
"keywords": ["sprint", "daily", "jira", "livrable", "prod"]
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"id": "startup",
|
| 86 |
+
"name": "Side Project — NoctaAI",
|
| 87 |
+
"contact": "yassine@noctaai.com",
|
| 88 |
+
"deadline": "2026-04-05T00:00:00",
|
| 89 |
+
"color": "#FF8800",
|
| 90 |
+
"keywords": ["NoctaAI", "MVP", "landing", "beta", "funding"]
|
| 91 |
+
}
|
| 92 |
+
]
|
| 93 |
+
}
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### Mock data
|
| 97 |
+
|
| 98 |
+
Emails, events, and search results are hardcoded in `mock_data.py` to tell a coherent story:
|
| 99 |
+
- **School:** TP deadline in 4 days, soutenance date confirmation needed
|
| 100 |
+
- **Company:** Manager silent 6 days, blocking dashboard bug, sprint review Monday with no prep block
|
| 101 |
+
- **Startup:** YC W26 opened this morning, landing page conversion low
|
| 102 |
+
|
| 103 |
+
## 6. Backend Components
|
| 104 |
+
|
| 105 |
+
### tools.py — 3 functions declared to Gemini
|
| 106 |
+
|
| 107 |
+
- `read_emails(project_id: str) -> str` — returns mock emails for a project
|
| 108 |
+
- `get_events(project_id: str) -> str` — returns mock calendar events
|
| 109 |
+
- `search_web(project_id: str) -> str` — returns mock search results for project keywords
|
| 110 |
+
|
| 111 |
+
Note: `score_urgency` is NOT a Gemini tool. It is called deterministically by the backend on all emails returned by `read_emails`. Gemini only generates the final brief text.
|
| 112 |
+
|
| 113 |
+
### urgency.py — HuggingFace scoring
|
| 114 |
+
|
| 115 |
+
- Model: `cross-encoder/ms-marco-MiniLM-L6-v2` (no hyphen between L and 6)
|
| 116 |
+
- Initialized with `CrossEncoder('cross-encoder/ms-marco-MiniLM-L6-v2', default_activation_function=torch.nn.Sigmoid())` to ensure output is normalized 0-1 (raw logits are unbounded without this)
|
| 117 |
+
- Loaded once at startup
|
| 118 |
+
- Scores relevance between query "urgent action required deadline missed no reply blocked" and email text
|
| 119 |
+
- Returns float 0-1 (after sigmoid activation)
|
| 120 |
+
|
| 121 |
+
### initiative.py — Deterministic alert rules
|
| 122 |
+
|
| 123 |
+
4 rules evaluated per project:
|
| 124 |
+
1. **Silence:** `days_since_reply >= 5` → URGENT
|
| 125 |
+
2. **Deadline:** `hours_to_deadline < 48` AND no prep block → URGENT
|
| 126 |
+
3. **External signal:** search urgency score > 0.6 → SIGNAL
|
| 127 |
+
4. **Email urgency:** any email score > 0.8 → URGENT
|
| 128 |
+
|
| 129 |
+
Output: `{status: "READY"|"URGENT"|"SIGNAL", alerts: [str]}`
|
| 130 |
+
|
| 131 |
+
### agent.py — Gemini function calling loop
|
| 132 |
+
|
| 133 |
+
1. Build system prompt (Operator persona)
|
| 134 |
+
2. Inject project list + 3 tools (read_emails, get_events, search_web)
|
| 135 |
+
3. Use the SDK's built-in `ChatSession` to handle thought signatures automatically (Gemini 3 mandates thought signatures during function calling — manual history manipulation will cause 400 errors)
|
| 136 |
+
4. Gemini calls tools autonomously
|
| 137 |
+
5. Each call emitted via `on_event()` callback
|
| 138 |
+
6. Tool results returned to Gemini via ChatSession
|
| 139 |
+
7. Backend runs `score_urgency()` + `evaluate_project()` deterministically on collected data
|
| 140 |
+
8. Initiative results injected into final Gemini prompt
|
| 141 |
+
9. Gemini generates final brief
|
| 142 |
+
10. **Max 15 tool call iterations, 30s total timeout.** If exceeded, force brief generation with data collected so far.
|
| 143 |
+
|
| 144 |
+
### main.py — FastAPI
|
| 145 |
+
|
| 146 |
+
- `GET /api/run` → starts the agent loop AND returns an SSE stream directly (no separate POST + GET — avoids race condition where events are lost between POST response and SSE connect)
|
| 147 |
+
- SSE event types:
|
| 148 |
+
- `tool_call` — tool name, project, status "running"
|
| 149 |
+
- `tool_result` — tool name, project, result, status "done"
|
| 150 |
+
- `urgency` — project, score
|
| 151 |
+
- `brief` — final brief text (no audio_b64 — TTS is browser-side via Web Speech API)
|
| 152 |
+
|
| 153 |
+
CORS enabled for Replit frontend domain.
|
| 154 |
+
|
| 155 |
+
## 7. Frontend Components (React)
|
| 156 |
+
|
| 157 |
+
### Layout
|
| 158 |
+
|
| 159 |
+
```
|
| 160 |
+
Header — "OPERATOR" logo + "Lancer le scan" button
|
| 161 |
+
ProjectCards — 3 cards side by side, color-coded border-left
|
| 162 |
+
ToolFeed — animated log of tool calls (slide-in, progress bars)
|
| 163 |
+
BriefPanel — typewriter text + Play audio button
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### Components
|
| 167 |
+
|
| 168 |
+
- `App.jsx` — layout, manages SSE connection
|
| 169 |
+
- `ProjectCard.jsx` — name, status badge (READY/URGENT/SIGNAL), alerts list, color
|
| 170 |
+
- `ToolFeed.jsx` — scrolling feed, each line: tool name + project + duration + progress bar
|
| 171 |
+
- `BriefPanel.jsx` — typewriter text effect + TTS play button
|
| 172 |
+
- `Header.jsx` — logo + trigger button
|
| 173 |
+
|
| 174 |
+
### Hook: useOperatorSSE(runId)
|
| 175 |
+
|
| 176 |
+
Consumes SSE stream, returns `{ toolCalls, projects, brief }`.
|
| 177 |
+
|
| 178 |
+
### Aesthetic
|
| 179 |
+
|
| 180 |
+
- Background: `#0a0a0a`
|
| 181 |
+
- Text: `#00FF88` (green terminal)
|
| 182 |
+
- Font: `JetBrains Mono` or `Fira Code`
|
| 183 |
+
- Cards: `border-left: 4px solid {project.color}`
|
| 184 |
+
- Tool feed: slide-in animation per line
|
| 185 |
+
- Brief: typewriter CSS effect
|
| 186 |
+
- Badges: pulsing red for URGENT
|
| 187 |
+
|
| 188 |
+
## 8. TTS
|
| 189 |
+
|
| 190 |
+
Primary: **Web Speech API** (browser-side, zero setup, no backend involvement)
|
| 191 |
+
- `lang: 'fr-FR'`, `rate: 1.1`, `pitch: 0.9`
|
| 192 |
+
- Frontend calls `speechSynthesis.speak()` directly with the brief text received via SSE
|
| 193 |
+
|
| 194 |
+
Fallback: **Google Cloud Text-to-Speech** via backend `tts.py` endpoint — only if Web Speech API sounds too robotic during rehearsal. In that case, add `audio_b64` to the `brief` SSE event.
|
| 195 |
+
|
| 196 |
+
## 9. System Prompt
|
| 197 |
+
|
| 198 |
+
```
|
| 199 |
+
You are Operator — a Jarvis-class autonomous agent for busy professionals.
|
| 200 |
+
You monitor multiple active projects simultaneously.
|
| 201 |
+
You speak first. You do not wait to be asked.
|
| 202 |
+
|
| 203 |
+
On startup:
|
| 204 |
+
1. Load all active projects from context
|
| 205 |
+
2. Scan Gmail, Calendar, and Search for each project
|
| 206 |
+
3. Score urgency using provided signals
|
| 207 |
+
4. Deliver a proactive multi-project status brief — unprompted
|
| 208 |
+
|
| 209 |
+
Rules:
|
| 210 |
+
- Never ask for clarification. Make reasonable assumptions.
|
| 211 |
+
- Never explain what you are doing. Just do it.
|
| 212 |
+
- Output must be 5 bullets max per project. Ruthlessly concise.
|
| 213 |
+
- If a tool fails, skip it and note the gap.
|
| 214 |
+
- Each project gets its own brief block. Never mix project contexts.
|
| 215 |
+
- Start with the most urgent project.
|
| 216 |
+
- The human is busy. Every word must earn its place.
|
| 217 |
+
- Speak in French.
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
## 10. Out of Scope (do NOT build)
|
| 221 |
+
|
| 222 |
+
- Mode prod (Gmail clustering via sentence-transformers)
|
| 223 |
+
- Real Gmail/Calendar API OAuth
|
| 224 |
+
- Continuous monitoring loop
|
| 225 |
+
- User authentication
|
| 226 |
+
- Database / persistence
|
| 227 |
+
- Deployment beyond Replit
|
| 228 |
+
|
| 229 |
+
## 11. Risks
|
| 230 |
+
|
| 231 |
+
| Risk | Mitigation |
|
| 232 |
+
|------|-----------|
|
| 233 |
+
| Gemini function calling unpredictable | Deterministic initiative engine as backbone; Gemini only generates text |
|
| 234 |
+
| HuggingFace model slow to load | Load once at startup, not per request |
|
| 235 |
+
| SSE connection drops | Frontend auto-reconnect + fallback polling |
|
| 236 |
+
| Demo breaks on stage | "Lancer le scan" button for manual trigger, rehearse 5x |
|
| 237 |
+
| Web Speech API sounds bad | Google Cloud TTS fallback ready |
|
| 238 |
+
| Gemini tool loop hangs | Max 15 iterations + 30s timeout, force brief with partial data |
|
| 239 |
+
|
| 240 |
+
## 12. Dependencies
|
| 241 |
+
|
| 242 |
+
### Python (backend)
|
| 243 |
+
- `google-genai` — Gemini 3 Flash SDK
|
| 244 |
+
- `sentence-transformers` — HuggingFace cross-encoder (installs `torch` CPU-only)
|
| 245 |
+
- `fastapi` — API framework
|
| 246 |
+
- `uvicorn` — ASGI server
|
| 247 |
+
- `sse-starlette` — Server-Sent Events for FastAPI
|
| 248 |
+
|
| 249 |
+
### Frontend (React on Replit)
|
| 250 |
+
- React template on Replit (includes react, react-dom, vite)
|
| 251 |
+
- No additional packages needed
|
| 252 |
+
|
| 253 |
+
### Environment variables
|
| 254 |
+
- `GOOGLE_API_KEY` — Gemini API key (from Google AI Studio)
|
| 255 |
+
- `HF_TOKEN` — HuggingFace token (optional, model is public)
|
frontend/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>OPERATOR</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,1677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "operator-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "operator-frontend",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"react": "^18.3.0",
|
| 12 |
+
"react-dom": "^18.3.0"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@vitejs/plugin-react": "^4.3.0",
|
| 16 |
+
"vite": "^5.4.0"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"node_modules/@babel/code-frame": {
|
| 20 |
+
"version": "7.29.0",
|
| 21 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
| 22 |
+
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
| 23 |
+
"dev": true,
|
| 24 |
+
"license": "MIT",
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 27 |
+
"js-tokens": "^4.0.0",
|
| 28 |
+
"picocolors": "^1.1.1"
|
| 29 |
+
},
|
| 30 |
+
"engines": {
|
| 31 |
+
"node": ">=6.9.0"
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"node_modules/@babel/compat-data": {
|
| 35 |
+
"version": "7.29.0",
|
| 36 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
| 37 |
+
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
| 38 |
+
"dev": true,
|
| 39 |
+
"license": "MIT",
|
| 40 |
+
"engines": {
|
| 41 |
+
"node": ">=6.9.0"
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"node_modules/@babel/core": {
|
| 45 |
+
"version": "7.29.0",
|
| 46 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 47 |
+
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 48 |
+
"dev": true,
|
| 49 |
+
"license": "MIT",
|
| 50 |
+
"dependencies": {
|
| 51 |
+
"@babel/code-frame": "^7.29.0",
|
| 52 |
+
"@babel/generator": "^7.29.0",
|
| 53 |
+
"@babel/helper-compilation-targets": "^7.28.6",
|
| 54 |
+
"@babel/helper-module-transforms": "^7.28.6",
|
| 55 |
+
"@babel/helpers": "^7.28.6",
|
| 56 |
+
"@babel/parser": "^7.29.0",
|
| 57 |
+
"@babel/template": "^7.28.6",
|
| 58 |
+
"@babel/traverse": "^7.29.0",
|
| 59 |
+
"@babel/types": "^7.29.0",
|
| 60 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 61 |
+
"convert-source-map": "^2.0.0",
|
| 62 |
+
"debug": "^4.1.0",
|
| 63 |
+
"gensync": "^1.0.0-beta.2",
|
| 64 |
+
"json5": "^2.2.3",
|
| 65 |
+
"semver": "^6.3.1"
|
| 66 |
+
},
|
| 67 |
+
"engines": {
|
| 68 |
+
"node": ">=6.9.0"
|
| 69 |
+
},
|
| 70 |
+
"funding": {
|
| 71 |
+
"type": "opencollective",
|
| 72 |
+
"url": "https://opencollective.com/babel"
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"node_modules/@babel/generator": {
|
| 76 |
+
"version": "7.29.1",
|
| 77 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
| 78 |
+
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
| 79 |
+
"dev": true,
|
| 80 |
+
"license": "MIT",
|
| 81 |
+
"dependencies": {
|
| 82 |
+
"@babel/parser": "^7.29.0",
|
| 83 |
+
"@babel/types": "^7.29.0",
|
| 84 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 85 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 86 |
+
"jsesc": "^3.0.2"
|
| 87 |
+
},
|
| 88 |
+
"engines": {
|
| 89 |
+
"node": ">=6.9.0"
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 93 |
+
"version": "7.28.6",
|
| 94 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
| 95 |
+
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
| 96 |
+
"dev": true,
|
| 97 |
+
"license": "MIT",
|
| 98 |
+
"dependencies": {
|
| 99 |
+
"@babel/compat-data": "^7.28.6",
|
| 100 |
+
"@babel/helper-validator-option": "^7.27.1",
|
| 101 |
+
"browserslist": "^4.24.0",
|
| 102 |
+
"lru-cache": "^5.1.1",
|
| 103 |
+
"semver": "^6.3.1"
|
| 104 |
+
},
|
| 105 |
+
"engines": {
|
| 106 |
+
"node": ">=6.9.0"
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
"node_modules/@babel/helper-globals": {
|
| 110 |
+
"version": "7.28.0",
|
| 111 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
| 112 |
+
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
| 113 |
+
"dev": true,
|
| 114 |
+
"license": "MIT",
|
| 115 |
+
"engines": {
|
| 116 |
+
"node": ">=6.9.0"
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
"node_modules/@babel/helper-module-imports": {
|
| 120 |
+
"version": "7.28.6",
|
| 121 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
| 122 |
+
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
| 123 |
+
"dev": true,
|
| 124 |
+
"license": "MIT",
|
| 125 |
+
"dependencies": {
|
| 126 |
+
"@babel/traverse": "^7.28.6",
|
| 127 |
+
"@babel/types": "^7.28.6"
|
| 128 |
+
},
|
| 129 |
+
"engines": {
|
| 130 |
+
"node": ">=6.9.0"
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 134 |
+
"version": "7.28.6",
|
| 135 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
| 136 |
+
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
| 137 |
+
"dev": true,
|
| 138 |
+
"license": "MIT",
|
| 139 |
+
"dependencies": {
|
| 140 |
+
"@babel/helper-module-imports": "^7.28.6",
|
| 141 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 142 |
+
"@babel/traverse": "^7.28.6"
|
| 143 |
+
},
|
| 144 |
+
"engines": {
|
| 145 |
+
"node": ">=6.9.0"
|
| 146 |
+
},
|
| 147 |
+
"peerDependencies": {
|
| 148 |
+
"@babel/core": "^7.0.0"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 152 |
+
"version": "7.28.6",
|
| 153 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
| 154 |
+
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
| 155 |
+
"dev": true,
|
| 156 |
+
"license": "MIT",
|
| 157 |
+
"engines": {
|
| 158 |
+
"node": ">=6.9.0"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"node_modules/@babel/helper-string-parser": {
|
| 162 |
+
"version": "7.27.1",
|
| 163 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
| 164 |
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
| 165 |
+
"dev": true,
|
| 166 |
+
"license": "MIT",
|
| 167 |
+
"engines": {
|
| 168 |
+
"node": ">=6.9.0"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 172 |
+
"version": "7.28.5",
|
| 173 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
| 174 |
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
| 175 |
+
"dev": true,
|
| 176 |
+
"license": "MIT",
|
| 177 |
+
"engines": {
|
| 178 |
+
"node": ">=6.9.0"
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
"node_modules/@babel/helper-validator-option": {
|
| 182 |
+
"version": "7.27.1",
|
| 183 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
| 184 |
+
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
| 185 |
+
"dev": true,
|
| 186 |
+
"license": "MIT",
|
| 187 |
+
"engines": {
|
| 188 |
+
"node": ">=6.9.0"
|
| 189 |
+
}
|
| 190 |
+
},
|
| 191 |
+
"node_modules/@babel/helpers": {
|
| 192 |
+
"version": "7.28.6",
|
| 193 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
| 194 |
+
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
| 195 |
+
"dev": true,
|
| 196 |
+
"license": "MIT",
|
| 197 |
+
"dependencies": {
|
| 198 |
+
"@babel/template": "^7.28.6",
|
| 199 |
+
"@babel/types": "^7.28.6"
|
| 200 |
+
},
|
| 201 |
+
"engines": {
|
| 202 |
+
"node": ">=6.9.0"
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
"node_modules/@babel/parser": {
|
| 206 |
+
"version": "7.29.0",
|
| 207 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
| 208 |
+
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
| 209 |
+
"dev": true,
|
| 210 |
+
"license": "MIT",
|
| 211 |
+
"dependencies": {
|
| 212 |
+
"@babel/types": "^7.29.0"
|
| 213 |
+
},
|
| 214 |
+
"bin": {
|
| 215 |
+
"parser": "bin/babel-parser.js"
|
| 216 |
+
},
|
| 217 |
+
"engines": {
|
| 218 |
+
"node": ">=6.0.0"
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 222 |
+
"version": "7.27.1",
|
| 223 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
| 224 |
+
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
| 225 |
+
"dev": true,
|
| 226 |
+
"license": "MIT",
|
| 227 |
+
"dependencies": {
|
| 228 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 229 |
+
},
|
| 230 |
+
"engines": {
|
| 231 |
+
"node": ">=6.9.0"
|
| 232 |
+
},
|
| 233 |
+
"peerDependencies": {
|
| 234 |
+
"@babel/core": "^7.0.0-0"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 238 |
+
"version": "7.27.1",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
| 240 |
+
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
| 241 |
+
"dev": true,
|
| 242 |
+
"license": "MIT",
|
| 243 |
+
"dependencies": {
|
| 244 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 245 |
+
},
|
| 246 |
+
"engines": {
|
| 247 |
+
"node": ">=6.9.0"
|
| 248 |
+
},
|
| 249 |
+
"peerDependencies": {
|
| 250 |
+
"@babel/core": "^7.0.0-0"
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
"node_modules/@babel/template": {
|
| 254 |
+
"version": "7.28.6",
|
| 255 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
| 256 |
+
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
| 257 |
+
"dev": true,
|
| 258 |
+
"license": "MIT",
|
| 259 |
+
"dependencies": {
|
| 260 |
+
"@babel/code-frame": "^7.28.6",
|
| 261 |
+
"@babel/parser": "^7.28.6",
|
| 262 |
+
"@babel/types": "^7.28.6"
|
| 263 |
+
},
|
| 264 |
+
"engines": {
|
| 265 |
+
"node": ">=6.9.0"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"node_modules/@babel/traverse": {
|
| 269 |
+
"version": "7.29.0",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
| 271 |
+
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
| 272 |
+
"dev": true,
|
| 273 |
+
"license": "MIT",
|
| 274 |
+
"dependencies": {
|
| 275 |
+
"@babel/code-frame": "^7.29.0",
|
| 276 |
+
"@babel/generator": "^7.29.0",
|
| 277 |
+
"@babel/helper-globals": "^7.28.0",
|
| 278 |
+
"@babel/parser": "^7.29.0",
|
| 279 |
+
"@babel/template": "^7.28.6",
|
| 280 |
+
"@babel/types": "^7.29.0",
|
| 281 |
+
"debug": "^4.3.1"
|
| 282 |
+
},
|
| 283 |
+
"engines": {
|
| 284 |
+
"node": ">=6.9.0"
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
"node_modules/@babel/types": {
|
| 288 |
+
"version": "7.29.0",
|
| 289 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
| 290 |
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
| 291 |
+
"dev": true,
|
| 292 |
+
"license": "MIT",
|
| 293 |
+
"dependencies": {
|
| 294 |
+
"@babel/helper-string-parser": "^7.27.1",
|
| 295 |
+
"@babel/helper-validator-identifier": "^7.28.5"
|
| 296 |
+
},
|
| 297 |
+
"engines": {
|
| 298 |
+
"node": ">=6.9.0"
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 302 |
+
"version": "0.21.5",
|
| 303 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 304 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 305 |
+
"cpu": [
|
| 306 |
+
"ppc64"
|
| 307 |
+
],
|
| 308 |
+
"dev": true,
|
| 309 |
+
"license": "MIT",
|
| 310 |
+
"optional": true,
|
| 311 |
+
"os": [
|
| 312 |
+
"aix"
|
| 313 |
+
],
|
| 314 |
+
"engines": {
|
| 315 |
+
"node": ">=12"
|
| 316 |
+
}
|
| 317 |
+
},
|
| 318 |
+
"node_modules/@esbuild/android-arm": {
|
| 319 |
+
"version": "0.21.5",
|
| 320 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 321 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 322 |
+
"cpu": [
|
| 323 |
+
"arm"
|
| 324 |
+
],
|
| 325 |
+
"dev": true,
|
| 326 |
+
"license": "MIT",
|
| 327 |
+
"optional": true,
|
| 328 |
+
"os": [
|
| 329 |
+
"android"
|
| 330 |
+
],
|
| 331 |
+
"engines": {
|
| 332 |
+
"node": ">=12"
|
| 333 |
+
}
|
| 334 |
+
},
|
| 335 |
+
"node_modules/@esbuild/android-arm64": {
|
| 336 |
+
"version": "0.21.5",
|
| 337 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 338 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 339 |
+
"cpu": [
|
| 340 |
+
"arm64"
|
| 341 |
+
],
|
| 342 |
+
"dev": true,
|
| 343 |
+
"license": "MIT",
|
| 344 |
+
"optional": true,
|
| 345 |
+
"os": [
|
| 346 |
+
"android"
|
| 347 |
+
],
|
| 348 |
+
"engines": {
|
| 349 |
+
"node": ">=12"
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
"node_modules/@esbuild/android-x64": {
|
| 353 |
+
"version": "0.21.5",
|
| 354 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 355 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 356 |
+
"cpu": [
|
| 357 |
+
"x64"
|
| 358 |
+
],
|
| 359 |
+
"dev": true,
|
| 360 |
+
"license": "MIT",
|
| 361 |
+
"optional": true,
|
| 362 |
+
"os": [
|
| 363 |
+
"android"
|
| 364 |
+
],
|
| 365 |
+
"engines": {
|
| 366 |
+
"node": ">=12"
|
| 367 |
+
}
|
| 368 |
+
},
|
| 369 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 370 |
+
"version": "0.21.5",
|
| 371 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 372 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 373 |
+
"cpu": [
|
| 374 |
+
"arm64"
|
| 375 |
+
],
|
| 376 |
+
"dev": true,
|
| 377 |
+
"license": "MIT",
|
| 378 |
+
"optional": true,
|
| 379 |
+
"os": [
|
| 380 |
+
"darwin"
|
| 381 |
+
],
|
| 382 |
+
"engines": {
|
| 383 |
+
"node": ">=12"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 387 |
+
"version": "0.21.5",
|
| 388 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 389 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 390 |
+
"cpu": [
|
| 391 |
+
"x64"
|
| 392 |
+
],
|
| 393 |
+
"dev": true,
|
| 394 |
+
"license": "MIT",
|
| 395 |
+
"optional": true,
|
| 396 |
+
"os": [
|
| 397 |
+
"darwin"
|
| 398 |
+
],
|
| 399 |
+
"engines": {
|
| 400 |
+
"node": ">=12"
|
| 401 |
+
}
|
| 402 |
+
},
|
| 403 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 404 |
+
"version": "0.21.5",
|
| 405 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 406 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 407 |
+
"cpu": [
|
| 408 |
+
"arm64"
|
| 409 |
+
],
|
| 410 |
+
"dev": true,
|
| 411 |
+
"license": "MIT",
|
| 412 |
+
"optional": true,
|
| 413 |
+
"os": [
|
| 414 |
+
"freebsd"
|
| 415 |
+
],
|
| 416 |
+
"engines": {
|
| 417 |
+
"node": ">=12"
|
| 418 |
+
}
|
| 419 |
+
},
|
| 420 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 421 |
+
"version": "0.21.5",
|
| 422 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 423 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 424 |
+
"cpu": [
|
| 425 |
+
"x64"
|
| 426 |
+
],
|
| 427 |
+
"dev": true,
|
| 428 |
+
"license": "MIT",
|
| 429 |
+
"optional": true,
|
| 430 |
+
"os": [
|
| 431 |
+
"freebsd"
|
| 432 |
+
],
|
| 433 |
+
"engines": {
|
| 434 |
+
"node": ">=12"
|
| 435 |
+
}
|
| 436 |
+
},
|
| 437 |
+
"node_modules/@esbuild/linux-arm": {
|
| 438 |
+
"version": "0.21.5",
|
| 439 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 440 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 441 |
+
"cpu": [
|
| 442 |
+
"arm"
|
| 443 |
+
],
|
| 444 |
+
"dev": true,
|
| 445 |
+
"license": "MIT",
|
| 446 |
+
"optional": true,
|
| 447 |
+
"os": [
|
| 448 |
+
"linux"
|
| 449 |
+
],
|
| 450 |
+
"engines": {
|
| 451 |
+
"node": ">=12"
|
| 452 |
+
}
|
| 453 |
+
},
|
| 454 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 455 |
+
"version": "0.21.5",
|
| 456 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 457 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 458 |
+
"cpu": [
|
| 459 |
+
"arm64"
|
| 460 |
+
],
|
| 461 |
+
"dev": true,
|
| 462 |
+
"license": "MIT",
|
| 463 |
+
"optional": true,
|
| 464 |
+
"os": [
|
| 465 |
+
"linux"
|
| 466 |
+
],
|
| 467 |
+
"engines": {
|
| 468 |
+
"node": ">=12"
|
| 469 |
+
}
|
| 470 |
+
},
|
| 471 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 472 |
+
"version": "0.21.5",
|
| 473 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 474 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 475 |
+
"cpu": [
|
| 476 |
+
"ia32"
|
| 477 |
+
],
|
| 478 |
+
"dev": true,
|
| 479 |
+
"license": "MIT",
|
| 480 |
+
"optional": true,
|
| 481 |
+
"os": [
|
| 482 |
+
"linux"
|
| 483 |
+
],
|
| 484 |
+
"engines": {
|
| 485 |
+
"node": ">=12"
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 489 |
+
"version": "0.21.5",
|
| 490 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 491 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 492 |
+
"cpu": [
|
| 493 |
+
"loong64"
|
| 494 |
+
],
|
| 495 |
+
"dev": true,
|
| 496 |
+
"license": "MIT",
|
| 497 |
+
"optional": true,
|
| 498 |
+
"os": [
|
| 499 |
+
"linux"
|
| 500 |
+
],
|
| 501 |
+
"engines": {
|
| 502 |
+
"node": ">=12"
|
| 503 |
+
}
|
| 504 |
+
},
|
| 505 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 506 |
+
"version": "0.21.5",
|
| 507 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 508 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 509 |
+
"cpu": [
|
| 510 |
+
"mips64el"
|
| 511 |
+
],
|
| 512 |
+
"dev": true,
|
| 513 |
+
"license": "MIT",
|
| 514 |
+
"optional": true,
|
| 515 |
+
"os": [
|
| 516 |
+
"linux"
|
| 517 |
+
],
|
| 518 |
+
"engines": {
|
| 519 |
+
"node": ">=12"
|
| 520 |
+
}
|
| 521 |
+
},
|
| 522 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 523 |
+
"version": "0.21.5",
|
| 524 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 525 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 526 |
+
"cpu": [
|
| 527 |
+
"ppc64"
|
| 528 |
+
],
|
| 529 |
+
"dev": true,
|
| 530 |
+
"license": "MIT",
|
| 531 |
+
"optional": true,
|
| 532 |
+
"os": [
|
| 533 |
+
"linux"
|
| 534 |
+
],
|
| 535 |
+
"engines": {
|
| 536 |
+
"node": ">=12"
|
| 537 |
+
}
|
| 538 |
+
},
|
| 539 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 540 |
+
"version": "0.21.5",
|
| 541 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 542 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 543 |
+
"cpu": [
|
| 544 |
+
"riscv64"
|
| 545 |
+
],
|
| 546 |
+
"dev": true,
|
| 547 |
+
"license": "MIT",
|
| 548 |
+
"optional": true,
|
| 549 |
+
"os": [
|
| 550 |
+
"linux"
|
| 551 |
+
],
|
| 552 |
+
"engines": {
|
| 553 |
+
"node": ">=12"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 557 |
+
"version": "0.21.5",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 559 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 560 |
+
"cpu": [
|
| 561 |
+
"s390x"
|
| 562 |
+
],
|
| 563 |
+
"dev": true,
|
| 564 |
+
"license": "MIT",
|
| 565 |
+
"optional": true,
|
| 566 |
+
"os": [
|
| 567 |
+
"linux"
|
| 568 |
+
],
|
| 569 |
+
"engines": {
|
| 570 |
+
"node": ">=12"
|
| 571 |
+
}
|
| 572 |
+
},
|
| 573 |
+
"node_modules/@esbuild/linux-x64": {
|
| 574 |
+
"version": "0.21.5",
|
| 575 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 576 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 577 |
+
"cpu": [
|
| 578 |
+
"x64"
|
| 579 |
+
],
|
| 580 |
+
"dev": true,
|
| 581 |
+
"license": "MIT",
|
| 582 |
+
"optional": true,
|
| 583 |
+
"os": [
|
| 584 |
+
"linux"
|
| 585 |
+
],
|
| 586 |
+
"engines": {
|
| 587 |
+
"node": ">=12"
|
| 588 |
+
}
|
| 589 |
+
},
|
| 590 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 591 |
+
"version": "0.21.5",
|
| 592 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 593 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 594 |
+
"cpu": [
|
| 595 |
+
"x64"
|
| 596 |
+
],
|
| 597 |
+
"dev": true,
|
| 598 |
+
"license": "MIT",
|
| 599 |
+
"optional": true,
|
| 600 |
+
"os": [
|
| 601 |
+
"netbsd"
|
| 602 |
+
],
|
| 603 |
+
"engines": {
|
| 604 |
+
"node": ">=12"
|
| 605 |
+
}
|
| 606 |
+
},
|
| 607 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 608 |
+
"version": "0.21.5",
|
| 609 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 610 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 611 |
+
"cpu": [
|
| 612 |
+
"x64"
|
| 613 |
+
],
|
| 614 |
+
"dev": true,
|
| 615 |
+
"license": "MIT",
|
| 616 |
+
"optional": true,
|
| 617 |
+
"os": [
|
| 618 |
+
"openbsd"
|
| 619 |
+
],
|
| 620 |
+
"engines": {
|
| 621 |
+
"node": ">=12"
|
| 622 |
+
}
|
| 623 |
+
},
|
| 624 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 625 |
+
"version": "0.21.5",
|
| 626 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 627 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 628 |
+
"cpu": [
|
| 629 |
+
"x64"
|
| 630 |
+
],
|
| 631 |
+
"dev": true,
|
| 632 |
+
"license": "MIT",
|
| 633 |
+
"optional": true,
|
| 634 |
+
"os": [
|
| 635 |
+
"sunos"
|
| 636 |
+
],
|
| 637 |
+
"engines": {
|
| 638 |
+
"node": ">=12"
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 642 |
+
"version": "0.21.5",
|
| 643 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 644 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 645 |
+
"cpu": [
|
| 646 |
+
"arm64"
|
| 647 |
+
],
|
| 648 |
+
"dev": true,
|
| 649 |
+
"license": "MIT",
|
| 650 |
+
"optional": true,
|
| 651 |
+
"os": [
|
| 652 |
+
"win32"
|
| 653 |
+
],
|
| 654 |
+
"engines": {
|
| 655 |
+
"node": ">=12"
|
| 656 |
+
}
|
| 657 |
+
},
|
| 658 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 659 |
+
"version": "0.21.5",
|
| 660 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 661 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 662 |
+
"cpu": [
|
| 663 |
+
"ia32"
|
| 664 |
+
],
|
| 665 |
+
"dev": true,
|
| 666 |
+
"license": "MIT",
|
| 667 |
+
"optional": true,
|
| 668 |
+
"os": [
|
| 669 |
+
"win32"
|
| 670 |
+
],
|
| 671 |
+
"engines": {
|
| 672 |
+
"node": ">=12"
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"node_modules/@esbuild/win32-x64": {
|
| 676 |
+
"version": "0.21.5",
|
| 677 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 678 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 679 |
+
"cpu": [
|
| 680 |
+
"x64"
|
| 681 |
+
],
|
| 682 |
+
"dev": true,
|
| 683 |
+
"license": "MIT",
|
| 684 |
+
"optional": true,
|
| 685 |
+
"os": [
|
| 686 |
+
"win32"
|
| 687 |
+
],
|
| 688 |
+
"engines": {
|
| 689 |
+
"node": ">=12"
|
| 690 |
+
}
|
| 691 |
+
},
|
| 692 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 693 |
+
"version": "0.3.13",
|
| 694 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 695 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 696 |
+
"dev": true,
|
| 697 |
+
"license": "MIT",
|
| 698 |
+
"dependencies": {
|
| 699 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 700 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 701 |
+
}
|
| 702 |
+
},
|
| 703 |
+
"node_modules/@jridgewell/remapping": {
|
| 704 |
+
"version": "2.3.5",
|
| 705 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 706 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 707 |
+
"dev": true,
|
| 708 |
+
"license": "MIT",
|
| 709 |
+
"dependencies": {
|
| 710 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 711 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 712 |
+
}
|
| 713 |
+
},
|
| 714 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 715 |
+
"version": "3.1.2",
|
| 716 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 717 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 718 |
+
"dev": true,
|
| 719 |
+
"license": "MIT",
|
| 720 |
+
"engines": {
|
| 721 |
+
"node": ">=6.0.0"
|
| 722 |
+
}
|
| 723 |
+
},
|
| 724 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 725 |
+
"version": "1.5.5",
|
| 726 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 727 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 728 |
+
"dev": true,
|
| 729 |
+
"license": "MIT"
|
| 730 |
+
},
|
| 731 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 732 |
+
"version": "0.3.31",
|
| 733 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 734 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 735 |
+
"dev": true,
|
| 736 |
+
"license": "MIT",
|
| 737 |
+
"dependencies": {
|
| 738 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 739 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 740 |
+
}
|
| 741 |
+
},
|
| 742 |
+
"node_modules/@rolldown/pluginutils": {
|
| 743 |
+
"version": "1.0.0-beta.27",
|
| 744 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 745 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 746 |
+
"dev": true,
|
| 747 |
+
"license": "MIT"
|
| 748 |
+
},
|
| 749 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 750 |
+
"version": "4.59.0",
|
| 751 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
| 752 |
+
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
| 753 |
+
"cpu": [
|
| 754 |
+
"arm"
|
| 755 |
+
],
|
| 756 |
+
"dev": true,
|
| 757 |
+
"license": "MIT",
|
| 758 |
+
"optional": true,
|
| 759 |
+
"os": [
|
| 760 |
+
"android"
|
| 761 |
+
]
|
| 762 |
+
},
|
| 763 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 764 |
+
"version": "4.59.0",
|
| 765 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
| 766 |
+
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
| 767 |
+
"cpu": [
|
| 768 |
+
"arm64"
|
| 769 |
+
],
|
| 770 |
+
"dev": true,
|
| 771 |
+
"license": "MIT",
|
| 772 |
+
"optional": true,
|
| 773 |
+
"os": [
|
| 774 |
+
"android"
|
| 775 |
+
]
|
| 776 |
+
},
|
| 777 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 778 |
+
"version": "4.59.0",
|
| 779 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
| 780 |
+
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
| 781 |
+
"cpu": [
|
| 782 |
+
"arm64"
|
| 783 |
+
],
|
| 784 |
+
"dev": true,
|
| 785 |
+
"license": "MIT",
|
| 786 |
+
"optional": true,
|
| 787 |
+
"os": [
|
| 788 |
+
"darwin"
|
| 789 |
+
]
|
| 790 |
+
},
|
| 791 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 792 |
+
"version": "4.59.0",
|
| 793 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
| 794 |
+
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
| 795 |
+
"cpu": [
|
| 796 |
+
"x64"
|
| 797 |
+
],
|
| 798 |
+
"dev": true,
|
| 799 |
+
"license": "MIT",
|
| 800 |
+
"optional": true,
|
| 801 |
+
"os": [
|
| 802 |
+
"darwin"
|
| 803 |
+
]
|
| 804 |
+
},
|
| 805 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 806 |
+
"version": "4.59.0",
|
| 807 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
| 808 |
+
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
| 809 |
+
"cpu": [
|
| 810 |
+
"arm64"
|
| 811 |
+
],
|
| 812 |
+
"dev": true,
|
| 813 |
+
"license": "MIT",
|
| 814 |
+
"optional": true,
|
| 815 |
+
"os": [
|
| 816 |
+
"freebsd"
|
| 817 |
+
]
|
| 818 |
+
},
|
| 819 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 820 |
+
"version": "4.59.0",
|
| 821 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
| 822 |
+
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
| 823 |
+
"cpu": [
|
| 824 |
+
"x64"
|
| 825 |
+
],
|
| 826 |
+
"dev": true,
|
| 827 |
+
"license": "MIT",
|
| 828 |
+
"optional": true,
|
| 829 |
+
"os": [
|
| 830 |
+
"freebsd"
|
| 831 |
+
]
|
| 832 |
+
},
|
| 833 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 834 |
+
"version": "4.59.0",
|
| 835 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
| 836 |
+
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
| 837 |
+
"cpu": [
|
| 838 |
+
"arm"
|
| 839 |
+
],
|
| 840 |
+
"dev": true,
|
| 841 |
+
"license": "MIT",
|
| 842 |
+
"optional": true,
|
| 843 |
+
"os": [
|
| 844 |
+
"linux"
|
| 845 |
+
]
|
| 846 |
+
},
|
| 847 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 848 |
+
"version": "4.59.0",
|
| 849 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
| 850 |
+
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
| 851 |
+
"cpu": [
|
| 852 |
+
"arm"
|
| 853 |
+
],
|
| 854 |
+
"dev": true,
|
| 855 |
+
"license": "MIT",
|
| 856 |
+
"optional": true,
|
| 857 |
+
"os": [
|
| 858 |
+
"linux"
|
| 859 |
+
]
|
| 860 |
+
},
|
| 861 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 862 |
+
"version": "4.59.0",
|
| 863 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
| 864 |
+
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
| 865 |
+
"cpu": [
|
| 866 |
+
"arm64"
|
| 867 |
+
],
|
| 868 |
+
"dev": true,
|
| 869 |
+
"license": "MIT",
|
| 870 |
+
"optional": true,
|
| 871 |
+
"os": [
|
| 872 |
+
"linux"
|
| 873 |
+
]
|
| 874 |
+
},
|
| 875 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 876 |
+
"version": "4.59.0",
|
| 877 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
| 878 |
+
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
| 879 |
+
"cpu": [
|
| 880 |
+
"arm64"
|
| 881 |
+
],
|
| 882 |
+
"dev": true,
|
| 883 |
+
"license": "MIT",
|
| 884 |
+
"optional": true,
|
| 885 |
+
"os": [
|
| 886 |
+
"linux"
|
| 887 |
+
]
|
| 888 |
+
},
|
| 889 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 890 |
+
"version": "4.59.0",
|
| 891 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
| 892 |
+
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
| 893 |
+
"cpu": [
|
| 894 |
+
"loong64"
|
| 895 |
+
],
|
| 896 |
+
"dev": true,
|
| 897 |
+
"license": "MIT",
|
| 898 |
+
"optional": true,
|
| 899 |
+
"os": [
|
| 900 |
+
"linux"
|
| 901 |
+
]
|
| 902 |
+
},
|
| 903 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 904 |
+
"version": "4.59.0",
|
| 905 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
| 906 |
+
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
| 907 |
+
"cpu": [
|
| 908 |
+
"loong64"
|
| 909 |
+
],
|
| 910 |
+
"dev": true,
|
| 911 |
+
"license": "MIT",
|
| 912 |
+
"optional": true,
|
| 913 |
+
"os": [
|
| 914 |
+
"linux"
|
| 915 |
+
]
|
| 916 |
+
},
|
| 917 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 918 |
+
"version": "4.59.0",
|
| 919 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
| 920 |
+
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
| 921 |
+
"cpu": [
|
| 922 |
+
"ppc64"
|
| 923 |
+
],
|
| 924 |
+
"dev": true,
|
| 925 |
+
"license": "MIT",
|
| 926 |
+
"optional": true,
|
| 927 |
+
"os": [
|
| 928 |
+
"linux"
|
| 929 |
+
]
|
| 930 |
+
},
|
| 931 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 932 |
+
"version": "4.59.0",
|
| 933 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
| 934 |
+
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
| 935 |
+
"cpu": [
|
| 936 |
+
"ppc64"
|
| 937 |
+
],
|
| 938 |
+
"dev": true,
|
| 939 |
+
"license": "MIT",
|
| 940 |
+
"optional": true,
|
| 941 |
+
"os": [
|
| 942 |
+
"linux"
|
| 943 |
+
]
|
| 944 |
+
},
|
| 945 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 946 |
+
"version": "4.59.0",
|
| 947 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
| 948 |
+
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
| 949 |
+
"cpu": [
|
| 950 |
+
"riscv64"
|
| 951 |
+
],
|
| 952 |
+
"dev": true,
|
| 953 |
+
"license": "MIT",
|
| 954 |
+
"optional": true,
|
| 955 |
+
"os": [
|
| 956 |
+
"linux"
|
| 957 |
+
]
|
| 958 |
+
},
|
| 959 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 960 |
+
"version": "4.59.0",
|
| 961 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
| 962 |
+
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
| 963 |
+
"cpu": [
|
| 964 |
+
"riscv64"
|
| 965 |
+
],
|
| 966 |
+
"dev": true,
|
| 967 |
+
"license": "MIT",
|
| 968 |
+
"optional": true,
|
| 969 |
+
"os": [
|
| 970 |
+
"linux"
|
| 971 |
+
]
|
| 972 |
+
},
|
| 973 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 974 |
+
"version": "4.59.0",
|
| 975 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
| 976 |
+
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
| 977 |
+
"cpu": [
|
| 978 |
+
"s390x"
|
| 979 |
+
],
|
| 980 |
+
"dev": true,
|
| 981 |
+
"license": "MIT",
|
| 982 |
+
"optional": true,
|
| 983 |
+
"os": [
|
| 984 |
+
"linux"
|
| 985 |
+
]
|
| 986 |
+
},
|
| 987 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 988 |
+
"version": "4.59.0",
|
| 989 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
| 990 |
+
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
| 991 |
+
"cpu": [
|
| 992 |
+
"x64"
|
| 993 |
+
],
|
| 994 |
+
"dev": true,
|
| 995 |
+
"license": "MIT",
|
| 996 |
+
"optional": true,
|
| 997 |
+
"os": [
|
| 998 |
+
"linux"
|
| 999 |
+
]
|
| 1000 |
+
},
|
| 1001 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1002 |
+
"version": "4.59.0",
|
| 1003 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
| 1004 |
+
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
| 1005 |
+
"cpu": [
|
| 1006 |
+
"x64"
|
| 1007 |
+
],
|
| 1008 |
+
"dev": true,
|
| 1009 |
+
"license": "MIT",
|
| 1010 |
+
"optional": true,
|
| 1011 |
+
"os": [
|
| 1012 |
+
"linux"
|
| 1013 |
+
]
|
| 1014 |
+
},
|
| 1015 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1016 |
+
"version": "4.59.0",
|
| 1017 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
| 1018 |
+
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
| 1019 |
+
"cpu": [
|
| 1020 |
+
"x64"
|
| 1021 |
+
],
|
| 1022 |
+
"dev": true,
|
| 1023 |
+
"license": "MIT",
|
| 1024 |
+
"optional": true,
|
| 1025 |
+
"os": [
|
| 1026 |
+
"openbsd"
|
| 1027 |
+
]
|
| 1028 |
+
},
|
| 1029 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1030 |
+
"version": "4.59.0",
|
| 1031 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
| 1032 |
+
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
| 1033 |
+
"cpu": [
|
| 1034 |
+
"arm64"
|
| 1035 |
+
],
|
| 1036 |
+
"dev": true,
|
| 1037 |
+
"license": "MIT",
|
| 1038 |
+
"optional": true,
|
| 1039 |
+
"os": [
|
| 1040 |
+
"openharmony"
|
| 1041 |
+
]
|
| 1042 |
+
},
|
| 1043 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1044 |
+
"version": "4.59.0",
|
| 1045 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
| 1046 |
+
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
| 1047 |
+
"cpu": [
|
| 1048 |
+
"arm64"
|
| 1049 |
+
],
|
| 1050 |
+
"dev": true,
|
| 1051 |
+
"license": "MIT",
|
| 1052 |
+
"optional": true,
|
| 1053 |
+
"os": [
|
| 1054 |
+
"win32"
|
| 1055 |
+
]
|
| 1056 |
+
},
|
| 1057 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1058 |
+
"version": "4.59.0",
|
| 1059 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
| 1060 |
+
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
| 1061 |
+
"cpu": [
|
| 1062 |
+
"ia32"
|
| 1063 |
+
],
|
| 1064 |
+
"dev": true,
|
| 1065 |
+
"license": "MIT",
|
| 1066 |
+
"optional": true,
|
| 1067 |
+
"os": [
|
| 1068 |
+
"win32"
|
| 1069 |
+
]
|
| 1070 |
+
},
|
| 1071 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1072 |
+
"version": "4.59.0",
|
| 1073 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
| 1074 |
+
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
| 1075 |
+
"cpu": [
|
| 1076 |
+
"x64"
|
| 1077 |
+
],
|
| 1078 |
+
"dev": true,
|
| 1079 |
+
"license": "MIT",
|
| 1080 |
+
"optional": true,
|
| 1081 |
+
"os": [
|
| 1082 |
+
"win32"
|
| 1083 |
+
]
|
| 1084 |
+
},
|
| 1085 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1086 |
+
"version": "4.59.0",
|
| 1087 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
| 1088 |
+
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
| 1089 |
+
"cpu": [
|
| 1090 |
+
"x64"
|
| 1091 |
+
],
|
| 1092 |
+
"dev": true,
|
| 1093 |
+
"license": "MIT",
|
| 1094 |
+
"optional": true,
|
| 1095 |
+
"os": [
|
| 1096 |
+
"win32"
|
| 1097 |
+
]
|
| 1098 |
+
},
|
| 1099 |
+
"node_modules/@types/babel__core": {
|
| 1100 |
+
"version": "7.20.5",
|
| 1101 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1102 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1103 |
+
"dev": true,
|
| 1104 |
+
"license": "MIT",
|
| 1105 |
+
"dependencies": {
|
| 1106 |
+
"@babel/parser": "^7.20.7",
|
| 1107 |
+
"@babel/types": "^7.20.7",
|
| 1108 |
+
"@types/babel__generator": "*",
|
| 1109 |
+
"@types/babel__template": "*",
|
| 1110 |
+
"@types/babel__traverse": "*"
|
| 1111 |
+
}
|
| 1112 |
+
},
|
| 1113 |
+
"node_modules/@types/babel__generator": {
|
| 1114 |
+
"version": "7.27.0",
|
| 1115 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1116 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1117 |
+
"dev": true,
|
| 1118 |
+
"license": "MIT",
|
| 1119 |
+
"dependencies": {
|
| 1120 |
+
"@babel/types": "^7.0.0"
|
| 1121 |
+
}
|
| 1122 |
+
},
|
| 1123 |
+
"node_modules/@types/babel__template": {
|
| 1124 |
+
"version": "7.4.4",
|
| 1125 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1126 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1127 |
+
"dev": true,
|
| 1128 |
+
"license": "MIT",
|
| 1129 |
+
"dependencies": {
|
| 1130 |
+
"@babel/parser": "^7.1.0",
|
| 1131 |
+
"@babel/types": "^7.0.0"
|
| 1132 |
+
}
|
| 1133 |
+
},
|
| 1134 |
+
"node_modules/@types/babel__traverse": {
|
| 1135 |
+
"version": "7.28.0",
|
| 1136 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1137 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1138 |
+
"dev": true,
|
| 1139 |
+
"license": "MIT",
|
| 1140 |
+
"dependencies": {
|
| 1141 |
+
"@babel/types": "^7.28.2"
|
| 1142 |
+
}
|
| 1143 |
+
},
|
| 1144 |
+
"node_modules/@types/estree": {
|
| 1145 |
+
"version": "1.0.8",
|
| 1146 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 1147 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 1148 |
+
"dev": true,
|
| 1149 |
+
"license": "MIT"
|
| 1150 |
+
},
|
| 1151 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1152 |
+
"version": "4.7.0",
|
| 1153 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1154 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1155 |
+
"dev": true,
|
| 1156 |
+
"license": "MIT",
|
| 1157 |
+
"dependencies": {
|
| 1158 |
+
"@babel/core": "^7.28.0",
|
| 1159 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1160 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1161 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1162 |
+
"@types/babel__core": "^7.20.5",
|
| 1163 |
+
"react-refresh": "^0.17.0"
|
| 1164 |
+
},
|
| 1165 |
+
"engines": {
|
| 1166 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1167 |
+
},
|
| 1168 |
+
"peerDependencies": {
|
| 1169 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1170 |
+
}
|
| 1171 |
+
},
|
| 1172 |
+
"node_modules/baseline-browser-mapping": {
|
| 1173 |
+
"version": "2.10.7",
|
| 1174 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
| 1175 |
+
"integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
|
| 1176 |
+
"dev": true,
|
| 1177 |
+
"license": "Apache-2.0",
|
| 1178 |
+
"bin": {
|
| 1179 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1180 |
+
},
|
| 1181 |
+
"engines": {
|
| 1182 |
+
"node": ">=6.0.0"
|
| 1183 |
+
}
|
| 1184 |
+
},
|
| 1185 |
+
"node_modules/browserslist": {
|
| 1186 |
+
"version": "4.28.1",
|
| 1187 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
| 1188 |
+
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
| 1189 |
+
"dev": true,
|
| 1190 |
+
"funding": [
|
| 1191 |
+
{
|
| 1192 |
+
"type": "opencollective",
|
| 1193 |
+
"url": "https://opencollective.com/browserslist"
|
| 1194 |
+
},
|
| 1195 |
+
{
|
| 1196 |
+
"type": "tidelift",
|
| 1197 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1198 |
+
},
|
| 1199 |
+
{
|
| 1200 |
+
"type": "github",
|
| 1201 |
+
"url": "https://github.com/sponsors/ai"
|
| 1202 |
+
}
|
| 1203 |
+
],
|
| 1204 |
+
"license": "MIT",
|
| 1205 |
+
"dependencies": {
|
| 1206 |
+
"baseline-browser-mapping": "^2.9.0",
|
| 1207 |
+
"caniuse-lite": "^1.0.30001759",
|
| 1208 |
+
"electron-to-chromium": "^1.5.263",
|
| 1209 |
+
"node-releases": "^2.0.27",
|
| 1210 |
+
"update-browserslist-db": "^1.2.0"
|
| 1211 |
+
},
|
| 1212 |
+
"bin": {
|
| 1213 |
+
"browserslist": "cli.js"
|
| 1214 |
+
},
|
| 1215 |
+
"engines": {
|
| 1216 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1217 |
+
}
|
| 1218 |
+
},
|
| 1219 |
+
"node_modules/caniuse-lite": {
|
| 1220 |
+
"version": "1.0.30001778",
|
| 1221 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
|
| 1222 |
+
"integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
|
| 1223 |
+
"dev": true,
|
| 1224 |
+
"funding": [
|
| 1225 |
+
{
|
| 1226 |
+
"type": "opencollective",
|
| 1227 |
+
"url": "https://opencollective.com/browserslist"
|
| 1228 |
+
},
|
| 1229 |
+
{
|
| 1230 |
+
"type": "tidelift",
|
| 1231 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1232 |
+
},
|
| 1233 |
+
{
|
| 1234 |
+
"type": "github",
|
| 1235 |
+
"url": "https://github.com/sponsors/ai"
|
| 1236 |
+
}
|
| 1237 |
+
],
|
| 1238 |
+
"license": "CC-BY-4.0"
|
| 1239 |
+
},
|
| 1240 |
+
"node_modules/convert-source-map": {
|
| 1241 |
+
"version": "2.0.0",
|
| 1242 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1243 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1244 |
+
"dev": true,
|
| 1245 |
+
"license": "MIT"
|
| 1246 |
+
},
|
| 1247 |
+
"node_modules/debug": {
|
| 1248 |
+
"version": "4.4.3",
|
| 1249 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1250 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1251 |
+
"dev": true,
|
| 1252 |
+
"license": "MIT",
|
| 1253 |
+
"dependencies": {
|
| 1254 |
+
"ms": "^2.1.3"
|
| 1255 |
+
},
|
| 1256 |
+
"engines": {
|
| 1257 |
+
"node": ">=6.0"
|
| 1258 |
+
},
|
| 1259 |
+
"peerDependenciesMeta": {
|
| 1260 |
+
"supports-color": {
|
| 1261 |
+
"optional": true
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
},
|
| 1265 |
+
"node_modules/electron-to-chromium": {
|
| 1266 |
+
"version": "1.5.313",
|
| 1267 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
| 1268 |
+
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
|
| 1269 |
+
"dev": true,
|
| 1270 |
+
"license": "ISC"
|
| 1271 |
+
},
|
| 1272 |
+
"node_modules/esbuild": {
|
| 1273 |
+
"version": "0.21.5",
|
| 1274 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1275 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1276 |
+
"dev": true,
|
| 1277 |
+
"hasInstallScript": true,
|
| 1278 |
+
"license": "MIT",
|
| 1279 |
+
"bin": {
|
| 1280 |
+
"esbuild": "bin/esbuild"
|
| 1281 |
+
},
|
| 1282 |
+
"engines": {
|
| 1283 |
+
"node": ">=12"
|
| 1284 |
+
},
|
| 1285 |
+
"optionalDependencies": {
|
| 1286 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1287 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1288 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1289 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1290 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1291 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1292 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1293 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1294 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1295 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1296 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1297 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1298 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1299 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1300 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1301 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1302 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1303 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1304 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1305 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1306 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1307 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1308 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1309 |
+
}
|
| 1310 |
+
},
|
| 1311 |
+
"node_modules/escalade": {
|
| 1312 |
+
"version": "3.2.0",
|
| 1313 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1314 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1315 |
+
"dev": true,
|
| 1316 |
+
"license": "MIT",
|
| 1317 |
+
"engines": {
|
| 1318 |
+
"node": ">=6"
|
| 1319 |
+
}
|
| 1320 |
+
},
|
| 1321 |
+
"node_modules/fsevents": {
|
| 1322 |
+
"version": "2.3.3",
|
| 1323 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1324 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1325 |
+
"dev": true,
|
| 1326 |
+
"hasInstallScript": true,
|
| 1327 |
+
"license": "MIT",
|
| 1328 |
+
"optional": true,
|
| 1329 |
+
"os": [
|
| 1330 |
+
"darwin"
|
| 1331 |
+
],
|
| 1332 |
+
"engines": {
|
| 1333 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1334 |
+
}
|
| 1335 |
+
},
|
| 1336 |
+
"node_modules/gensync": {
|
| 1337 |
+
"version": "1.0.0-beta.2",
|
| 1338 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1339 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1340 |
+
"dev": true,
|
| 1341 |
+
"license": "MIT",
|
| 1342 |
+
"engines": {
|
| 1343 |
+
"node": ">=6.9.0"
|
| 1344 |
+
}
|
| 1345 |
+
},
|
| 1346 |
+
"node_modules/js-tokens": {
|
| 1347 |
+
"version": "4.0.0",
|
| 1348 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1349 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1350 |
+
"license": "MIT"
|
| 1351 |
+
},
|
| 1352 |
+
"node_modules/jsesc": {
|
| 1353 |
+
"version": "3.1.0",
|
| 1354 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1355 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1356 |
+
"dev": true,
|
| 1357 |
+
"license": "MIT",
|
| 1358 |
+
"bin": {
|
| 1359 |
+
"jsesc": "bin/jsesc"
|
| 1360 |
+
},
|
| 1361 |
+
"engines": {
|
| 1362 |
+
"node": ">=6"
|
| 1363 |
+
}
|
| 1364 |
+
},
|
| 1365 |
+
"node_modules/json5": {
|
| 1366 |
+
"version": "2.2.3",
|
| 1367 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1368 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1369 |
+
"dev": true,
|
| 1370 |
+
"license": "MIT",
|
| 1371 |
+
"bin": {
|
| 1372 |
+
"json5": "lib/cli.js"
|
| 1373 |
+
},
|
| 1374 |
+
"engines": {
|
| 1375 |
+
"node": ">=6"
|
| 1376 |
+
}
|
| 1377 |
+
},
|
| 1378 |
+
"node_modules/loose-envify": {
|
| 1379 |
+
"version": "1.4.0",
|
| 1380 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 1381 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 1382 |
+
"license": "MIT",
|
| 1383 |
+
"dependencies": {
|
| 1384 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 1385 |
+
},
|
| 1386 |
+
"bin": {
|
| 1387 |
+
"loose-envify": "cli.js"
|
| 1388 |
+
}
|
| 1389 |
+
},
|
| 1390 |
+
"node_modules/lru-cache": {
|
| 1391 |
+
"version": "5.1.1",
|
| 1392 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1393 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1394 |
+
"dev": true,
|
| 1395 |
+
"license": "ISC",
|
| 1396 |
+
"dependencies": {
|
| 1397 |
+
"yallist": "^3.0.2"
|
| 1398 |
+
}
|
| 1399 |
+
},
|
| 1400 |
+
"node_modules/ms": {
|
| 1401 |
+
"version": "2.1.3",
|
| 1402 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1403 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1404 |
+
"dev": true,
|
| 1405 |
+
"license": "MIT"
|
| 1406 |
+
},
|
| 1407 |
+
"node_modules/nanoid": {
|
| 1408 |
+
"version": "3.3.11",
|
| 1409 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 1410 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
| 1411 |
+
"dev": true,
|
| 1412 |
+
"funding": [
|
| 1413 |
+
{
|
| 1414 |
+
"type": "github",
|
| 1415 |
+
"url": "https://github.com/sponsors/ai"
|
| 1416 |
+
}
|
| 1417 |
+
],
|
| 1418 |
+
"license": "MIT",
|
| 1419 |
+
"bin": {
|
| 1420 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1421 |
+
},
|
| 1422 |
+
"engines": {
|
| 1423 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1424 |
+
}
|
| 1425 |
+
},
|
| 1426 |
+
"node_modules/node-releases": {
|
| 1427 |
+
"version": "2.0.36",
|
| 1428 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
| 1429 |
+
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
| 1430 |
+
"dev": true,
|
| 1431 |
+
"license": "MIT"
|
| 1432 |
+
},
|
| 1433 |
+
"node_modules/picocolors": {
|
| 1434 |
+
"version": "1.1.1",
|
| 1435 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1436 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1437 |
+
"dev": true,
|
| 1438 |
+
"license": "ISC"
|
| 1439 |
+
},
|
| 1440 |
+
"node_modules/postcss": {
|
| 1441 |
+
"version": "8.5.8",
|
| 1442 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
| 1443 |
+
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
| 1444 |
+
"dev": true,
|
| 1445 |
+
"funding": [
|
| 1446 |
+
{
|
| 1447 |
+
"type": "opencollective",
|
| 1448 |
+
"url": "https://opencollective.com/postcss/"
|
| 1449 |
+
},
|
| 1450 |
+
{
|
| 1451 |
+
"type": "tidelift",
|
| 1452 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1453 |
+
},
|
| 1454 |
+
{
|
| 1455 |
+
"type": "github",
|
| 1456 |
+
"url": "https://github.com/sponsors/ai"
|
| 1457 |
+
}
|
| 1458 |
+
],
|
| 1459 |
+
"license": "MIT",
|
| 1460 |
+
"dependencies": {
|
| 1461 |
+
"nanoid": "^3.3.11",
|
| 1462 |
+
"picocolors": "^1.1.1",
|
| 1463 |
+
"source-map-js": "^1.2.1"
|
| 1464 |
+
},
|
| 1465 |
+
"engines": {
|
| 1466 |
+
"node": "^10 || ^12 || >=14"
|
| 1467 |
+
}
|
| 1468 |
+
},
|
| 1469 |
+
"node_modules/react": {
|
| 1470 |
+
"version": "18.3.1",
|
| 1471 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1472 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1473 |
+
"license": "MIT",
|
| 1474 |
+
"dependencies": {
|
| 1475 |
+
"loose-envify": "^1.1.0"
|
| 1476 |
+
},
|
| 1477 |
+
"engines": {
|
| 1478 |
+
"node": ">=0.10.0"
|
| 1479 |
+
}
|
| 1480 |
+
},
|
| 1481 |
+
"node_modules/react-dom": {
|
| 1482 |
+
"version": "18.3.1",
|
| 1483 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 1484 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 1485 |
+
"license": "MIT",
|
| 1486 |
+
"dependencies": {
|
| 1487 |
+
"loose-envify": "^1.1.0",
|
| 1488 |
+
"scheduler": "^0.23.2"
|
| 1489 |
+
},
|
| 1490 |
+
"peerDependencies": {
|
| 1491 |
+
"react": "^18.3.1"
|
| 1492 |
+
}
|
| 1493 |
+
},
|
| 1494 |
+
"node_modules/react-refresh": {
|
| 1495 |
+
"version": "0.17.0",
|
| 1496 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 1497 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 1498 |
+
"dev": true,
|
| 1499 |
+
"license": "MIT",
|
| 1500 |
+
"engines": {
|
| 1501 |
+
"node": ">=0.10.0"
|
| 1502 |
+
}
|
| 1503 |
+
},
|
| 1504 |
+
"node_modules/rollup": {
|
| 1505 |
+
"version": "4.59.0",
|
| 1506 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
| 1507 |
+
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
| 1508 |
+
"dev": true,
|
| 1509 |
+
"license": "MIT",
|
| 1510 |
+
"dependencies": {
|
| 1511 |
+
"@types/estree": "1.0.8"
|
| 1512 |
+
},
|
| 1513 |
+
"bin": {
|
| 1514 |
+
"rollup": "dist/bin/rollup"
|
| 1515 |
+
},
|
| 1516 |
+
"engines": {
|
| 1517 |
+
"node": ">=18.0.0",
|
| 1518 |
+
"npm": ">=8.0.0"
|
| 1519 |
+
},
|
| 1520 |
+
"optionalDependencies": {
|
| 1521 |
+
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
| 1522 |
+
"@rollup/rollup-android-arm64": "4.59.0",
|
| 1523 |
+
"@rollup/rollup-darwin-arm64": "4.59.0",
|
| 1524 |
+
"@rollup/rollup-darwin-x64": "4.59.0",
|
| 1525 |
+
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
| 1526 |
+
"@rollup/rollup-freebsd-x64": "4.59.0",
|
| 1527 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
| 1528 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
| 1529 |
+
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
| 1530 |
+
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
| 1531 |
+
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
| 1532 |
+
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
| 1533 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
| 1534 |
+
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
| 1535 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
| 1536 |
+
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
| 1537 |
+
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
| 1538 |
+
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
| 1539 |
+
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
| 1540 |
+
"@rollup/rollup-openbsd-x64": "4.59.0",
|
| 1541 |
+
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
| 1542 |
+
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
| 1543 |
+
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
| 1544 |
+
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
| 1545 |
+
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
| 1546 |
+
"fsevents": "~2.3.2"
|
| 1547 |
+
}
|
| 1548 |
+
},
|
| 1549 |
+
"node_modules/scheduler": {
|
| 1550 |
+
"version": "0.23.2",
|
| 1551 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 1552 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 1553 |
+
"license": "MIT",
|
| 1554 |
+
"dependencies": {
|
| 1555 |
+
"loose-envify": "^1.1.0"
|
| 1556 |
+
}
|
| 1557 |
+
},
|
| 1558 |
+
"node_modules/semver": {
|
| 1559 |
+
"version": "6.3.1",
|
| 1560 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 1561 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 1562 |
+
"dev": true,
|
| 1563 |
+
"license": "ISC",
|
| 1564 |
+
"bin": {
|
| 1565 |
+
"semver": "bin/semver.js"
|
| 1566 |
+
}
|
| 1567 |
+
},
|
| 1568 |
+
"node_modules/source-map-js": {
|
| 1569 |
+
"version": "1.2.1",
|
| 1570 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1571 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1572 |
+
"dev": true,
|
| 1573 |
+
"license": "BSD-3-Clause",
|
| 1574 |
+
"engines": {
|
| 1575 |
+
"node": ">=0.10.0"
|
| 1576 |
+
}
|
| 1577 |
+
},
|
| 1578 |
+
"node_modules/update-browserslist-db": {
|
| 1579 |
+
"version": "1.2.3",
|
| 1580 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 1581 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 1582 |
+
"dev": true,
|
| 1583 |
+
"funding": [
|
| 1584 |
+
{
|
| 1585 |
+
"type": "opencollective",
|
| 1586 |
+
"url": "https://opencollective.com/browserslist"
|
| 1587 |
+
},
|
| 1588 |
+
{
|
| 1589 |
+
"type": "tidelift",
|
| 1590 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1591 |
+
},
|
| 1592 |
+
{
|
| 1593 |
+
"type": "github",
|
| 1594 |
+
"url": "https://github.com/sponsors/ai"
|
| 1595 |
+
}
|
| 1596 |
+
],
|
| 1597 |
+
"license": "MIT",
|
| 1598 |
+
"dependencies": {
|
| 1599 |
+
"escalade": "^3.2.0",
|
| 1600 |
+
"picocolors": "^1.1.1"
|
| 1601 |
+
},
|
| 1602 |
+
"bin": {
|
| 1603 |
+
"update-browserslist-db": "cli.js"
|
| 1604 |
+
},
|
| 1605 |
+
"peerDependencies": {
|
| 1606 |
+
"browserslist": ">= 4.21.0"
|
| 1607 |
+
}
|
| 1608 |
+
},
|
| 1609 |
+
"node_modules/vite": {
|
| 1610 |
+
"version": "5.4.21",
|
| 1611 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1612 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1613 |
+
"dev": true,
|
| 1614 |
+
"license": "MIT",
|
| 1615 |
+
"dependencies": {
|
| 1616 |
+
"esbuild": "^0.21.3",
|
| 1617 |
+
"postcss": "^8.4.43",
|
| 1618 |
+
"rollup": "^4.20.0"
|
| 1619 |
+
},
|
| 1620 |
+
"bin": {
|
| 1621 |
+
"vite": "bin/vite.js"
|
| 1622 |
+
},
|
| 1623 |
+
"engines": {
|
| 1624 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1625 |
+
},
|
| 1626 |
+
"funding": {
|
| 1627 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1628 |
+
},
|
| 1629 |
+
"optionalDependencies": {
|
| 1630 |
+
"fsevents": "~2.3.3"
|
| 1631 |
+
},
|
| 1632 |
+
"peerDependencies": {
|
| 1633 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1634 |
+
"less": "*",
|
| 1635 |
+
"lightningcss": "^1.21.0",
|
| 1636 |
+
"sass": "*",
|
| 1637 |
+
"sass-embedded": "*",
|
| 1638 |
+
"stylus": "*",
|
| 1639 |
+
"sugarss": "*",
|
| 1640 |
+
"terser": "^5.4.0"
|
| 1641 |
+
},
|
| 1642 |
+
"peerDependenciesMeta": {
|
| 1643 |
+
"@types/node": {
|
| 1644 |
+
"optional": true
|
| 1645 |
+
},
|
| 1646 |
+
"less": {
|
| 1647 |
+
"optional": true
|
| 1648 |
+
},
|
| 1649 |
+
"lightningcss": {
|
| 1650 |
+
"optional": true
|
| 1651 |
+
},
|
| 1652 |
+
"sass": {
|
| 1653 |
+
"optional": true
|
| 1654 |
+
},
|
| 1655 |
+
"sass-embedded": {
|
| 1656 |
+
"optional": true
|
| 1657 |
+
},
|
| 1658 |
+
"stylus": {
|
| 1659 |
+
"optional": true
|
| 1660 |
+
},
|
| 1661 |
+
"sugarss": {
|
| 1662 |
+
"optional": true
|
| 1663 |
+
},
|
| 1664 |
+
"terser": {
|
| 1665 |
+
"optional": true
|
| 1666 |
+
}
|
| 1667 |
+
}
|
| 1668 |
+
},
|
| 1669 |
+
"node_modules/yallist": {
|
| 1670 |
+
"version": "3.1.1",
|
| 1671 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 1672 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 1673 |
+
"dev": true,
|
| 1674 |
+
"license": "ISC"
|
| 1675 |
+
}
|
| 1676 |
+
}
|
| 1677 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "operator-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.0",
|
| 13 |
+
"react-dom": "^18.3.0"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@vitejs/plugin-react": "^4.3.0",
|
| 17 |
+
"vite": "^5.4.0"
|
| 18 |
+
}
|
| 19 |
+
}
|
frontend/public/avatar.png
ADDED
|
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef } from 'react';
|
| 2 |
+
import JarvisOrb from './components/JarvisOrb';
|
| 3 |
+
import ConversationOverlay from './components/ConversationOverlay';
|
| 4 |
+
import ProjectCardsDrawer from './components/ProjectCardsDrawer';
|
| 5 |
+
import useOperatorSSE from './hooks/useOperatorSSE';
|
| 6 |
+
import useWakeWord from './hooks/useWakeWord';
|
| 7 |
+
import useSpeechRecognition from './hooks/useSpeechRecognition';
|
| 8 |
+
import useTTS from './hooks/useTTS';
|
| 9 |
+
import useGoogleAuth from './hooks/useGoogleAuth';
|
| 10 |
+
|
| 11 |
+
const PROJECTS = [
|
| 12 |
+
{ id: 'school', name: 'Master IA \u2014 Sorbonne', contact: 'prof.martinez@sorbonne.fr', color: '#4285f4' },
|
| 13 |
+
{ id: 'company', name: 'Alternance \u2014 BNP Paribas', contact: 'sophie.renard@bnpparibas.com', color: '#ea4335' },
|
| 14 |
+
{ id: 'startup', name: 'Side Project \u2014 NoctaAI', contact: 'yassine@noctaai.com', color: '#fbbc04' },
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
export default function App() {
|
| 18 |
+
const { sendChat } = useOperatorSSE();
|
| 19 |
+
const { speak } = useTTS();
|
| 20 |
+
const { authenticated, oauthAvailable, login, logout } = useGoogleAuth();
|
| 21 |
+
|
| 22 |
+
// Start directly in IDLE — no scan needed
|
| 23 |
+
const [phase, setPhase] = useState('IDLE');
|
| 24 |
+
const [messages, setMessages] = useState([]);
|
| 25 |
+
const phaseRef = useRef(phase);
|
| 26 |
+
phaseRef.current = phase;
|
| 27 |
+
|
| 28 |
+
const [inputText, setInputText] = useState('');
|
| 29 |
+
const [showTranscript, setShowTranscript] = useState(false);
|
| 30 |
+
|
| 31 |
+
const handleWakeWord = useCallback(() => {
|
| 32 |
+
if (phaseRef.current === 'IDLE') setPhase('LISTENING');
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const { micActive, micError, requestMic } = useWakeWord({
|
| 36 |
+
enabled: phase === 'IDLE',
|
| 37 |
+
onDetected: handleWakeWord,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const handleOrbClick = useCallback(() => {
|
| 41 |
+
if (phase === 'IDLE') {
|
| 42 |
+
if (micError || !micActive) {
|
| 43 |
+
// First click: authorize mic — wake word listener starts
|
| 44 |
+
requestMic();
|
| 45 |
+
} else {
|
| 46 |
+
// Mic already active — go to listening
|
| 47 |
+
setPhase('LISTENING');
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}, [phase, micError, micActive, requestMic]);
|
| 51 |
+
|
| 52 |
+
const handleUserSaid = useCallback(async (text) => {
|
| 53 |
+
if (!text.trim()) return;
|
| 54 |
+
|
| 55 |
+
const bye = text.toLowerCase();
|
| 56 |
+
if (bye.includes('merci') || bye.includes("c'est bon") || bye.includes('a plus') || bye.includes('au revoir')) {
|
| 57 |
+
setMessages(prev => [...prev, { role: 'user', text }]);
|
| 58 |
+
const farewell = "Ok, je reste la si tu as besoin. Dis 'Oppy' quand tu veux.";
|
| 59 |
+
setMessages(prev => [...prev, { role: 'assistant', text: farewell }]);
|
| 60 |
+
setPhase('SPEAKING');
|
| 61 |
+
await speak(farewell);
|
| 62 |
+
setPhase('IDLE');
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
setMessages(prev => [...prev, { role: 'user', text }]);
|
| 67 |
+
setPhase('SPEAKING');
|
| 68 |
+
const acks = [
|
| 69 |
+
"D'accord, donne-moi un petit instant.",
|
| 70 |
+
"Je regarde ca tout de suite.",
|
| 71 |
+
"Laisse-moi verifier.",
|
| 72 |
+
"Une seconde, je cherche.",
|
| 73 |
+
];
|
| 74 |
+
const ack = acks[Math.floor(Math.random() * acks.length)];
|
| 75 |
+
const ackDone = speak(ack);
|
| 76 |
+
const replyPromise = sendChat(text);
|
| 77 |
+
await ackDone;
|
| 78 |
+
setPhase('THINKING');
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
const reply = await replyPromise;
|
| 82 |
+
if (reply) {
|
| 83 |
+
setMessages(prev => [...prev, { role: 'assistant', text: reply }]);
|
| 84 |
+
setPhase('SPEAKING');
|
| 85 |
+
await speak(reply);
|
| 86 |
+
if (phaseRef.current === 'SPEAKING') setPhase('LISTENING');
|
| 87 |
+
} else {
|
| 88 |
+
setPhase('LISTENING');
|
| 89 |
+
}
|
| 90 |
+
} catch (err) {
|
| 91 |
+
console.error('Chat error:', err);
|
| 92 |
+
setPhase('LISTENING');
|
| 93 |
+
}
|
| 94 |
+
}, [sendChat, speak]);
|
| 95 |
+
|
| 96 |
+
const handleTimeout = useCallback(() => {
|
| 97 |
+
if (phaseRef.current === 'LISTENING') setPhase('IDLE');
|
| 98 |
+
}, []);
|
| 99 |
+
|
| 100 |
+
const { transcript } = useSpeechRecognition({
|
| 101 |
+
enabled: phase === 'LISTENING',
|
| 102 |
+
onResult: handleUserSaid,
|
| 103 |
+
onTimeout: handleTimeout,
|
| 104 |
+
timeoutMs: 20000,
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const handleSubmit = (e) => {
|
| 108 |
+
e.preventDefault();
|
| 109 |
+
const text = inputText.trim();
|
| 110 |
+
if (!text || phase === 'THINKING' || phase === 'SPEAKING') return;
|
| 111 |
+
setInputText('');
|
| 112 |
+
handleUserSaid(text);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
let label = '';
|
| 116 |
+
if (phase === 'IDLE') {
|
| 117 |
+
if (micError) label = "Clique sur l'orbe pour autoriser le micro";
|
| 118 |
+
else if (!micActive) label = "Clique sur l'orbe pour activer le micro";
|
| 119 |
+
else label = "Micro actif — dis 'Oppy' pour commencer";
|
| 120 |
+
}
|
| 121 |
+
else if (phase === 'LISTENING') label = transcript || "Je t'ecoute...";
|
| 122 |
+
else if (phase === 'THINKING') label = 'Oppy reflechit...';
|
| 123 |
+
else if (phase === 'SPEAKING') label = '';
|
| 124 |
+
|
| 125 |
+
const micDotColor = phase === 'IDLE' && micActive ? '#34a853' : phase === 'IDLE' && micError ? '#ea4335' : null;
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div style={{
|
| 129 |
+
minHeight: '100vh',
|
| 130 |
+
display: 'flex',
|
| 131 |
+
flexDirection: 'column',
|
| 132 |
+
alignItems: 'center',
|
| 133 |
+
justifyContent: 'center',
|
| 134 |
+
padding: '40px 20px 100px',
|
| 135 |
+
position: 'relative',
|
| 136 |
+
}}>
|
| 137 |
+
{/* Logo */}
|
| 138 |
+
<div style={{
|
| 139 |
+
position: 'fixed',
|
| 140 |
+
top: 0,
|
| 141 |
+
left: 0,
|
| 142 |
+
padding: '20px 24px',
|
| 143 |
+
zIndex: 10,
|
| 144 |
+
display: 'flex',
|
| 145 |
+
alignItems: 'center',
|
| 146 |
+
gap: '10px',
|
| 147 |
+
}}>
|
| 148 |
+
<span style={{
|
| 149 |
+
fontSize: '20px',
|
| 150 |
+
background: 'linear-gradient(135deg, #4285f4, #a142f4, #f439a0)',
|
| 151 |
+
WebkitBackgroundClip: 'text',
|
| 152 |
+
WebkitTextFillColor: 'transparent',
|
| 153 |
+
}}>
|
| 154 |
+
✦
|
| 155 |
+
</span>
|
| 156 |
+
<h1 style={{
|
| 157 |
+
fontSize: '16px',
|
| 158 |
+
fontWeight: 500,
|
| 159 |
+
color: '#1f1f1f',
|
| 160 |
+
margin: 0,
|
| 161 |
+
letterSpacing: '0.5px',
|
| 162 |
+
}}>
|
| 163 |
+
Oppy
|
| 164 |
+
</h1>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Google Auth status */}
|
| 168 |
+
{oauthAvailable && (
|
| 169 |
+
<div style={{
|
| 170 |
+
position: 'fixed',
|
| 171 |
+
top: 0,
|
| 172 |
+
right: 0,
|
| 173 |
+
padding: '20px 24px',
|
| 174 |
+
zIndex: 10,
|
| 175 |
+
display: 'flex',
|
| 176 |
+
alignItems: 'center',
|
| 177 |
+
gap: '10px',
|
| 178 |
+
}}>
|
| 179 |
+
<span style={{
|
| 180 |
+
fontSize: '11px',
|
| 181 |
+
color: authenticated ? '#34a853' : '#9aa0a6',
|
| 182 |
+
display: 'flex',
|
| 183 |
+
alignItems: 'center',
|
| 184 |
+
gap: '6px',
|
| 185 |
+
}}>
|
| 186 |
+
<span style={{
|
| 187 |
+
width: '8px',
|
| 188 |
+
height: '8px',
|
| 189 |
+
borderRadius: '50%',
|
| 190 |
+
background: authenticated ? '#34a853' : '#9aa0a6',
|
| 191 |
+
display: 'inline-block',
|
| 192 |
+
}} />
|
| 193 |
+
{authenticated ? 'Google connecte' : 'Mode demo'}
|
| 194 |
+
</span>
|
| 195 |
+
<button
|
| 196 |
+
onClick={authenticated ? logout : login}
|
| 197 |
+
style={{
|
| 198 |
+
background: 'transparent',
|
| 199 |
+
color: authenticated ? '#9aa0a6' : '#4285f4',
|
| 200 |
+
border: `1px solid ${authenticated ? '#e0e0e0' : '#4285f4'}`,
|
| 201 |
+
padding: '6px 12px',
|
| 202 |
+
borderRadius: '16px',
|
| 203 |
+
fontFamily: 'inherit',
|
| 204 |
+
fontSize: '11px',
|
| 205 |
+
fontWeight: 500,
|
| 206 |
+
cursor: 'pointer',
|
| 207 |
+
transition: 'all 0.2s',
|
| 208 |
+
}}
|
| 209 |
+
>
|
| 210 |
+
{authenticated ? 'Deconnecter' : 'Connecter Google'}
|
| 211 |
+
</button>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
|
| 215 |
+
{/* Orb */}
|
| 216 |
+
<JarvisOrb phase={phase} onClick={handleOrbClick} isLoading={false} />
|
| 217 |
+
|
| 218 |
+
{/* Mic status dot */}
|
| 219 |
+
{micDotColor && (
|
| 220 |
+
<div style={{
|
| 221 |
+
marginTop: '12px',
|
| 222 |
+
display: 'flex',
|
| 223 |
+
alignItems: 'center',
|
| 224 |
+
gap: '6px',
|
| 225 |
+
}}>
|
| 226 |
+
<div style={{
|
| 227 |
+
width: '8px', height: '8px', borderRadius: '50%',
|
| 228 |
+
background: micDotColor,
|
| 229 |
+
animation: micActive ? 'pulse 2s infinite' : 'none',
|
| 230 |
+
}} />
|
| 231 |
+
<span style={{ fontSize: '11px', color: micDotColor, letterSpacing: '0.5px' }}>
|
| 232 |
+
{micActive ? 'Micro actif' : 'Micro bloque'}
|
| 233 |
+
</span>
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
|
| 237 |
+
{/* Phase label */}
|
| 238 |
+
<div style={{
|
| 239 |
+
marginTop: micDotColor ? '8px' : '24px',
|
| 240 |
+
fontSize: '14px',
|
| 241 |
+
color: phase === 'LISTENING' && transcript ? '#4285f4' : '#5f6368',
|
| 242 |
+
textAlign: 'center',
|
| 243 |
+
minHeight: '20px',
|
| 244 |
+
animation: phase === 'THINKING' ? 'pulse 1.5s infinite' : 'none',
|
| 245 |
+
fontWeight: phase === 'LISTENING' && transcript ? 500 : 400,
|
| 246 |
+
}}>
|
| 247 |
+
{label}
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Transcript toggle */}
|
| 251 |
+
{messages.length > 0 && (
|
| 252 |
+
<div style={{ marginTop: '16px', textAlign: 'center' }}>
|
| 253 |
+
<button
|
| 254 |
+
onClick={() => setShowTranscript(!showTranscript)}
|
| 255 |
+
style={{
|
| 256 |
+
background: showTranscript ? '#f1f3f4' : 'transparent',
|
| 257 |
+
border: '1px solid #e0e0e0',
|
| 258 |
+
color: '#5f6368',
|
| 259 |
+
padding: '6px 16px',
|
| 260 |
+
borderRadius: '16px',
|
| 261 |
+
fontFamily: 'inherit',
|
| 262 |
+
fontSize: '12px',
|
| 263 |
+
cursor: 'pointer',
|
| 264 |
+
transition: 'all 0.2s',
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
+
{showTranscript ? 'Masquer la transcription' : 'Afficher la transcription'}
|
| 268 |
+
</button>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
{showTranscript && (
|
| 272 |
+
<div style={{ marginTop: '12px', width: '100%', maxWidth: '600px' }}>
|
| 273 |
+
<ConversationOverlay messages={messages} isThinking={phase === 'THINKING'} />
|
| 274 |
+
</div>
|
| 275 |
+
)}
|
| 276 |
+
|
| 277 |
+
{/* Text input */}
|
| 278 |
+
<form
|
| 279 |
+
onSubmit={handleSubmit}
|
| 280 |
+
style={{
|
| 281 |
+
position: 'fixed',
|
| 282 |
+
bottom: '50px',
|
| 283 |
+
left: '50%',
|
| 284 |
+
transform: 'translateX(-50%)',
|
| 285 |
+
width: '100%',
|
| 286 |
+
maxWidth: '500px',
|
| 287 |
+
display: 'flex',
|
| 288 |
+
gap: '8px',
|
| 289 |
+
padding: '0 20px',
|
| 290 |
+
zIndex: 15,
|
| 291 |
+
}}
|
| 292 |
+
>
|
| 293 |
+
<input
|
| 294 |
+
type="text"
|
| 295 |
+
value={inputText}
|
| 296 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 297 |
+
placeholder="Ecris ta question ici..."
|
| 298 |
+
style={{
|
| 299 |
+
flex: 1,
|
| 300 |
+
background: '#f8f9fa',
|
| 301 |
+
border: '1px solid #e0e0e0',
|
| 302 |
+
borderRadius: '24px',
|
| 303 |
+
padding: '12px 20px',
|
| 304 |
+
color: '#1f1f1f',
|
| 305 |
+
fontFamily: 'inherit',
|
| 306 |
+
fontSize: '14px',
|
| 307 |
+
outline: 'none',
|
| 308 |
+
}}
|
| 309 |
+
/>
|
| 310 |
+
<button
|
| 311 |
+
type="submit"
|
| 312 |
+
disabled={!inputText.trim() || phase === 'THINKING'}
|
| 313 |
+
style={{
|
| 314 |
+
background: inputText.trim() ? '#4285f4' : '#f1f3f4',
|
| 315 |
+
color: inputText.trim() ? '#fff' : '#9aa0a6',
|
| 316 |
+
border: 'none',
|
| 317 |
+
borderRadius: '24px',
|
| 318 |
+
padding: '12px 20px',
|
| 319 |
+
fontFamily: 'inherit',
|
| 320 |
+
fontSize: '13px',
|
| 321 |
+
fontWeight: 500,
|
| 322 |
+
cursor: !inputText.trim() ? 'not-allowed' : 'pointer',
|
| 323 |
+
transition: 'all 0.2s',
|
| 324 |
+
}}
|
| 325 |
+
>
|
| 326 |
+
Envoyer
|
| 327 |
+
</button>
|
| 328 |
+
</form>
|
| 329 |
+
|
| 330 |
+
{/* Project cards drawer */}
|
| 331 |
+
<ProjectCardsDrawer projects={PROJECTS} statuses={{}} />
|
| 332 |
+
</div>
|
| 333 |
+
);
|
| 334 |
+
}
|
frontend/src/components/BriefPanel.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
export default function BriefPanel({ briefText }) {
|
| 6 |
+
const [displayedText, setDisplayedText] = useState('');
|
| 7 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 8 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 9 |
+
const audioRef = useRef(null);
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (!briefText) {
|
| 13 |
+
setDisplayedText('');
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
// Typewriter effect
|
| 17 |
+
setDisplayedText('');
|
| 18 |
+
let i = 0;
|
| 19 |
+
const interval = setInterval(() => {
|
| 20 |
+
if (i < briefText.length) {
|
| 21 |
+
setDisplayedText(briefText.slice(0, i + 1));
|
| 22 |
+
i++;
|
| 23 |
+
} else {
|
| 24 |
+
clearInterval(interval);
|
| 25 |
+
}
|
| 26 |
+
}, 12);
|
| 27 |
+
|
| 28 |
+
// Auto-launch TTS in parallel
|
| 29 |
+
(async () => {
|
| 30 |
+
setIsLoading(true);
|
| 31 |
+
try {
|
| 32 |
+
const res = await fetch(`${BACKEND_URL}/api/tts`, {
|
| 33 |
+
method: 'POST',
|
| 34 |
+
headers: { 'Content-Type': 'application/json' },
|
| 35 |
+
body: JSON.stringify({ text: briefText }),
|
| 36 |
+
});
|
| 37 |
+
if (!res.ok) throw new Error('TTS failed');
|
| 38 |
+
const blob = await res.blob();
|
| 39 |
+
const url = URL.createObjectURL(blob);
|
| 40 |
+
const audio = new Audio(url);
|
| 41 |
+
audioRef.current = audio;
|
| 42 |
+
audio.onended = () => {
|
| 43 |
+
setIsPlaying(false);
|
| 44 |
+
URL.revokeObjectURL(url);
|
| 45 |
+
};
|
| 46 |
+
setIsPlaying(true);
|
| 47 |
+
await audio.play();
|
| 48 |
+
} catch (err) {
|
| 49 |
+
console.error('TTS auto-play error:', err);
|
| 50 |
+
} finally {
|
| 51 |
+
setIsLoading(false);
|
| 52 |
+
}
|
| 53 |
+
})();
|
| 54 |
+
|
| 55 |
+
return () => clearInterval(interval);
|
| 56 |
+
}, [briefText]);
|
| 57 |
+
|
| 58 |
+
const handleSpeak = async () => {
|
| 59 |
+
if (isPlaying) {
|
| 60 |
+
if (audioRef.current) {
|
| 61 |
+
audioRef.current.pause();
|
| 62 |
+
audioRef.current.currentTime = 0;
|
| 63 |
+
}
|
| 64 |
+
setIsPlaying(false);
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
setIsLoading(true);
|
| 69 |
+
try {
|
| 70 |
+
const res = await fetch(`${BACKEND_URL}/api/tts`, {
|
| 71 |
+
method: 'POST',
|
| 72 |
+
headers: { 'Content-Type': 'application/json' },
|
| 73 |
+
body: JSON.stringify({ text: briefText }),
|
| 74 |
+
});
|
| 75 |
+
if (!res.ok) throw new Error('TTS failed');
|
| 76 |
+
|
| 77 |
+
const blob = await res.blob();
|
| 78 |
+
const url = URL.createObjectURL(blob);
|
| 79 |
+
const audio = new Audio(url);
|
| 80 |
+
audioRef.current = audio;
|
| 81 |
+
|
| 82 |
+
audio.onended = () => {
|
| 83 |
+
setIsPlaying(false);
|
| 84 |
+
URL.revokeObjectURL(url);
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
setIsPlaying(true);
|
| 88 |
+
await audio.play();
|
| 89 |
+
} catch (err) {
|
| 90 |
+
console.error('TTS error:', err);
|
| 91 |
+
} finally {
|
| 92 |
+
setIsLoading(false);
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
if (!briefText) {
|
| 97 |
+
return null;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<div style={{
|
| 102 |
+
background: 'var(--card-bg)',
|
| 103 |
+
padding: '24px',
|
| 104 |
+
borderTop: '1px solid var(--border)',
|
| 105 |
+
animation: 'fadeIn 0.5s ease-out',
|
| 106 |
+
}}>
|
| 107 |
+
<div style={{
|
| 108 |
+
display: 'flex',
|
| 109 |
+
justifyContent: 'space-between',
|
| 110 |
+
alignItems: 'center',
|
| 111 |
+
marginBottom: '16px',
|
| 112 |
+
}}>
|
| 113 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px' }}>
|
| 114 |
+
BRIEF PROACTIF
|
| 115 |
+
</h4>
|
| 116 |
+
<button
|
| 117 |
+
onClick={handleSpeak}
|
| 118 |
+
disabled={isLoading}
|
| 119 |
+
style={{
|
| 120 |
+
background: 'none',
|
| 121 |
+
border: '1px solid var(--green)',
|
| 122 |
+
color: 'var(--green)',
|
| 123 |
+
padding: '6px 16px',
|
| 124 |
+
fontFamily: 'inherit',
|
| 125 |
+
fontSize: '12px',
|
| 126 |
+
cursor: isLoading ? 'wait' : 'pointer',
|
| 127 |
+
transition: 'all 0.2s',
|
| 128 |
+
opacity: isLoading ? 0.6 : 1,
|
| 129 |
+
}}
|
| 130 |
+
>
|
| 131 |
+
{isLoading ? '\u23F3 Chargement...' : isPlaying ? '\u23F9 Stop' : '\uD83D\uDD0A Play'}
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
<div style={{
|
| 135 |
+
fontSize: '14px',
|
| 136 |
+
lineHeight: '1.7',
|
| 137 |
+
whiteSpace: 'pre-wrap',
|
| 138 |
+
color: '#e0e0e0',
|
| 139 |
+
}}>
|
| 140 |
+
{displayedText}
|
| 141 |
+
{displayedText.length < (briefText?.length || 0) && (
|
| 142 |
+
<span style={{ animation: 'pulse 0.8s infinite', color: 'var(--green)' }}>|</span>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|
frontend/src/components/ConversationOverlay.jsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
|
| 3 |
+
export default function ConversationOverlay({ messages, isThinking }) {
|
| 4 |
+
const endRef = useRef(null);
|
| 5 |
+
|
| 6 |
+
useEffect(() => {
|
| 7 |
+
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 8 |
+
}, [messages, isThinking]);
|
| 9 |
+
|
| 10 |
+
if ((!messages || messages.length === 0) && !isThinking) return null;
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<div style={{
|
| 14 |
+
width: '100%',
|
| 15 |
+
maxWidth: '600px',
|
| 16 |
+
maxHeight: '40vh',
|
| 17 |
+
overflowY: 'auto',
|
| 18 |
+
display: 'flex',
|
| 19 |
+
flexDirection: 'column',
|
| 20 |
+
gap: '10px',
|
| 21 |
+
padding: '10px 0',
|
| 22 |
+
}}>
|
| 23 |
+
{messages.map((msg, i) => (
|
| 24 |
+
<div key={i} style={{
|
| 25 |
+
padding: '12px 16px',
|
| 26 |
+
borderRadius: '12px',
|
| 27 |
+
fontSize: '14px',
|
| 28 |
+
lineHeight: '1.6',
|
| 29 |
+
whiteSpace: 'pre-wrap',
|
| 30 |
+
background: msg.role === 'user' ? '#e8f0fe' : '#f8f9fa',
|
| 31 |
+
borderLeft: `3px solid ${msg.role === 'user' ? '#4285f4' : '#a142f4'}`,
|
| 32 |
+
color: '#1f1f1f',
|
| 33 |
+
animation: 'fadeInUp 0.3s ease-out',
|
| 34 |
+
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
| 35 |
+
maxWidth: '85%',
|
| 36 |
+
}}>
|
| 37 |
+
<span style={{
|
| 38 |
+
fontSize: '11px',
|
| 39 |
+
color: msg.role === 'user' ? '#4285f4' : '#a142f4',
|
| 40 |
+
fontWeight: 500,
|
| 41 |
+
display: 'block',
|
| 42 |
+
marginBottom: '4px',
|
| 43 |
+
}}>
|
| 44 |
+
{msg.role === 'user' ? 'Toi' : 'Oppy'}
|
| 45 |
+
</span>
|
| 46 |
+
{msg.text}
|
| 47 |
+
</div>
|
| 48 |
+
))}
|
| 49 |
+
|
| 50 |
+
{isThinking && (
|
| 51 |
+
<div style={{
|
| 52 |
+
padding: '12px 16px',
|
| 53 |
+
fontSize: '14px',
|
| 54 |
+
color: '#a142f4',
|
| 55 |
+
fontStyle: 'italic',
|
| 56 |
+
animation: 'pulse 1.5s infinite',
|
| 57 |
+
}}>
|
| 58 |
+
Oppy reflechit...
|
| 59 |
+
</div>
|
| 60 |
+
)}
|
| 61 |
+
|
| 62 |
+
<div ref={endRef} />
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
frontend/src/components/Header.jsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Header({ scanProgress, scanPhase, authenticated, oauthAvailable, onLogin, onLogout }) {
|
| 2 |
+
return (
|
| 3 |
+
<header style={{
|
| 4 |
+
padding: '24px 32px',
|
| 5 |
+
borderBottom: '1px solid var(--border)',
|
| 6 |
+
}}>
|
| 7 |
+
<div style={{
|
| 8 |
+
display: 'flex',
|
| 9 |
+
justifyContent: 'space-between',
|
| 10 |
+
alignItems: 'center',
|
| 11 |
+
}}>
|
| 12 |
+
<div>
|
| 13 |
+
<h1 style={{
|
| 14 |
+
fontSize: '28px',
|
| 15 |
+
fontWeight: 700,
|
| 16 |
+
color: 'var(--green)',
|
| 17 |
+
letterSpacing: '4px',
|
| 18 |
+
}}>
|
| 19 |
+
■ OPERATOR
|
| 20 |
+
</h1>
|
| 21 |
+
<p style={{
|
| 22 |
+
fontSize: '12px',
|
| 23 |
+
color: 'var(--text-dim)',
|
| 24 |
+
marginTop: '4px',
|
| 25 |
+
fontStyle: 'italic',
|
| 26 |
+
}}>
|
| 27 |
+
The AI Agent That Works While You Talk.
|
| 28 |
+
</p>
|
| 29 |
+
</div>
|
| 30 |
+
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
| 31 |
+
{oauthAvailable && (
|
| 32 |
+
<>
|
| 33 |
+
<span style={{
|
| 34 |
+
fontSize: '11px',
|
| 35 |
+
color: authenticated ? 'var(--green)' : 'var(--text-dim)',
|
| 36 |
+
display: 'flex',
|
| 37 |
+
alignItems: 'center',
|
| 38 |
+
gap: '6px',
|
| 39 |
+
}}>
|
| 40 |
+
<span style={{
|
| 41 |
+
width: '8px',
|
| 42 |
+
height: '8px',
|
| 43 |
+
borderRadius: '50%',
|
| 44 |
+
background: authenticated ? 'var(--green)' : 'var(--text-dim)',
|
| 45 |
+
display: 'inline-block',
|
| 46 |
+
}} />
|
| 47 |
+
{authenticated ? 'GOOGLE CONNECTED' : 'DEMO MODE'}
|
| 48 |
+
</span>
|
| 49 |
+
<button
|
| 50 |
+
onClick={authenticated ? onLogout : onLogin}
|
| 51 |
+
style={{
|
| 52 |
+
background: 'transparent',
|
| 53 |
+
color: authenticated ? 'var(--text-dim)' : '#4285F4',
|
| 54 |
+
border: `1px solid ${authenticated ? 'var(--border)' : '#4285F4'}`,
|
| 55 |
+
padding: '8px 16px',
|
| 56 |
+
fontFamily: 'inherit',
|
| 57 |
+
fontSize: '11px',
|
| 58 |
+
fontWeight: 700,
|
| 59 |
+
cursor: 'pointer',
|
| 60 |
+
letterSpacing: '1px',
|
| 61 |
+
transition: 'all 0.2s',
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
{authenticated ? 'DISCONNECT' : 'CONNECT GOOGLE'}
|
| 65 |
+
</button>
|
| 66 |
+
</>
|
| 67 |
+
)}
|
| 68 |
+
{scanProgress > 0 && scanProgress < 100 && (
|
| 69 |
+
<span style={{
|
| 70 |
+
fontSize: '12px',
|
| 71 |
+
color: 'var(--green)',
|
| 72 |
+
letterSpacing: '2px',
|
| 73 |
+
fontWeight: 700,
|
| 74 |
+
animation: 'pulse 1.5s infinite',
|
| 75 |
+
}}>
|
| 76 |
+
SCANNING...
|
| 77 |
+
</span>
|
| 78 |
+
)}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Progress bar */}
|
| 83 |
+
{scanProgress > 0 && scanProgress < 100 && (
|
| 84 |
+
<div style={{ marginTop: '16px' }}>
|
| 85 |
+
<div style={{
|
| 86 |
+
display: 'flex',
|
| 87 |
+
justifyContent: 'space-between',
|
| 88 |
+
alignItems: 'center',
|
| 89 |
+
marginBottom: '6px',
|
| 90 |
+
}}>
|
| 91 |
+
<span style={{
|
| 92 |
+
fontSize: '11px',
|
| 93 |
+
color: 'var(--text-dim)',
|
| 94 |
+
letterSpacing: '1px',
|
| 95 |
+
}}>
|
| 96 |
+
{scanPhase}
|
| 97 |
+
</span>
|
| 98 |
+
<span style={{
|
| 99 |
+
fontSize: '11px',
|
| 100 |
+
color: 'var(--text-dim)',
|
| 101 |
+
fontVariantNumeric: 'tabular-nums',
|
| 102 |
+
}}>
|
| 103 |
+
{scanProgress}%
|
| 104 |
+
</span>
|
| 105 |
+
</div>
|
| 106 |
+
<div style={{
|
| 107 |
+
width: '100%',
|
| 108 |
+
height: '6px',
|
| 109 |
+
background: '#1a1a1a',
|
| 110 |
+
borderRadius: '3px',
|
| 111 |
+
overflow: 'hidden',
|
| 112 |
+
}}>
|
| 113 |
+
<div style={{
|
| 114 |
+
width: `${scanProgress}%`,
|
| 115 |
+
height: '100%',
|
| 116 |
+
background: 'linear-gradient(90deg, var(--green), #00cc6a)',
|
| 117 |
+
borderRadius: '3px',
|
| 118 |
+
boxShadow: '0 0 8px rgba(0, 255, 136, 0.4)',
|
| 119 |
+
}} />
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
)}
|
| 123 |
+
</header>
|
| 124 |
+
);
|
| 125 |
+
}
|
frontend/src/components/JarvisOrb.jsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import OppyFace from './OppyFace';
|
| 2 |
+
|
| 3 |
+
const phaseStyles = {
|
| 4 |
+
LOADING: {
|
| 5 |
+
background: 'radial-gradient(circle, rgba(66,133,244,0.2) 0%, transparent 70%)',
|
| 6 |
+
boxShadow: '0 0 40px rgba(66,133,244,0.1)',
|
| 7 |
+
animation: 'breathe 4s ease-in-out infinite',
|
| 8 |
+
opacity: 0.4,
|
| 9 |
+
},
|
| 10 |
+
IDLE: {
|
| 11 |
+
background: 'radial-gradient(circle, rgba(66,133,244,0.35) 0%, rgba(161,66,244,0.15) 50%, transparent 70%)',
|
| 12 |
+
boxShadow: '0 0 50px rgba(66,133,244,0.2), 0 0 100px rgba(161,66,244,0.08)',
|
| 13 |
+
animation: 'breathe 3s ease-in-out infinite',
|
| 14 |
+
opacity: 0.75,
|
| 15 |
+
},
|
| 16 |
+
LISTENING: {
|
| 17 |
+
background: 'radial-gradient(circle, rgba(66,133,244,0.5) 0%, rgba(161,66,244,0.25) 50%, transparent 70%)',
|
| 18 |
+
boxShadow: '0 0 60px rgba(66,133,244,0.35), 0 0 120px rgba(161,66,244,0.12)',
|
| 19 |
+
animation: 'listenPulse 1.5s ease-in-out infinite',
|
| 20 |
+
opacity: 1,
|
| 21 |
+
},
|
| 22 |
+
THINKING: {
|
| 23 |
+
background: 'radial-gradient(circle, rgba(161,66,244,0.5) 0%, rgba(244,57,160,0.25) 50%, transparent 70%)',
|
| 24 |
+
boxShadow: '0 0 60px rgba(161,66,244,0.35), 0 0 120px rgba(244,57,160,0.12)',
|
| 25 |
+
animation: 'thinkSpin 2s linear infinite',
|
| 26 |
+
opacity: 0.9,
|
| 27 |
+
},
|
| 28 |
+
SPEAKING: {
|
| 29 |
+
background: 'radial-gradient(circle, rgba(66,133,244,0.55) 0%, rgba(161,66,244,0.35) 40%, rgba(244,57,160,0.15) 70%, transparent 80%)',
|
| 30 |
+
boxShadow: '0 0 70px rgba(66,133,244,0.3), 0 0 130px rgba(161,66,244,0.15)',
|
| 31 |
+
animation: 'speakPulse 0.8s ease-in-out infinite',
|
| 32 |
+
opacity: 1,
|
| 33 |
+
},
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export default function JarvisOrb({ phase, onClick, isLoading }) {
|
| 37 |
+
const style = phaseStyles[phase] || phaseStyles.LOADING;
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div
|
| 41 |
+
onClick={onClick}
|
| 42 |
+
style={{
|
| 43 |
+
position: 'relative',
|
| 44 |
+
width: '180px',
|
| 45 |
+
height: '180px',
|
| 46 |
+
cursor: phase === 'IDLE' || phase === 'LOADING' ? 'pointer' : 'default',
|
| 47 |
+
}}
|
| 48 |
+
>
|
| 49 |
+
{/* Loading ring */}
|
| 50 |
+
{isLoading && (
|
| 51 |
+
<svg
|
| 52 |
+
viewBox="0 0 200 200"
|
| 53 |
+
style={{
|
| 54 |
+
position: 'absolute', inset: '-10px', width: '200px', height: '200px',
|
| 55 |
+
animation: 'orbSpin 1.8s linear infinite',
|
| 56 |
+
}}
|
| 57 |
+
>
|
| 58 |
+
<circle cx="100" cy="100" r="94" fill="none" stroke="rgba(66,133,244,0.1)" strokeWidth="2" />
|
| 59 |
+
<circle
|
| 60 |
+
cx="100" cy="100" r="94" fill="none" stroke="url(#geminiGrad)"
|
| 61 |
+
strokeWidth="2.5" strokeLinecap="round" strokeDasharray="140 460"
|
| 62 |
+
/>
|
| 63 |
+
<defs>
|
| 64 |
+
<linearGradient id="geminiGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
| 65 |
+
<stop offset="0%" stopColor="#4285f4" stopOpacity="0" />
|
| 66 |
+
<stop offset="40%" stopColor="#4285f4" stopOpacity="1" />
|
| 67 |
+
<stop offset="70%" stopColor="#a142f4" stopOpacity="1" />
|
| 68 |
+
<stop offset="100%" stopColor="#f439a0" stopOpacity="0.3" />
|
| 69 |
+
</linearGradient>
|
| 70 |
+
</defs>
|
| 71 |
+
</svg>
|
| 72 |
+
)}
|
| 73 |
+
|
| 74 |
+
{/* Ripple rings for LISTENING */}
|
| 75 |
+
{phase === 'LISTENING' && (
|
| 76 |
+
<>
|
| 77 |
+
<div style={{
|
| 78 |
+
position: 'absolute', inset: '-20px', borderRadius: '50%',
|
| 79 |
+
border: '1px solid rgba(66,133,244,0.3)',
|
| 80 |
+
animation: 'ripple 2s ease-out infinite',
|
| 81 |
+
}} />
|
| 82 |
+
<div style={{
|
| 83 |
+
position: 'absolute', inset: '-20px', borderRadius: '50%',
|
| 84 |
+
border: '1px solid rgba(161,66,244,0.2)',
|
| 85 |
+
animation: 'ripple 2s ease-out infinite 0.6s',
|
| 86 |
+
}} />
|
| 87 |
+
</>
|
| 88 |
+
)}
|
| 89 |
+
|
| 90 |
+
{/* Glow background */}
|
| 91 |
+
<div style={{
|
| 92 |
+
width: '100%', height: '100%', borderRadius: '50%',
|
| 93 |
+
transition: 'opacity 0.5s',
|
| 94 |
+
...style,
|
| 95 |
+
}} />
|
| 96 |
+
|
| 97 |
+
{/* Oppy face SVG — centered, fills the orb */}
|
| 98 |
+
<div style={{
|
| 99 |
+
position: 'absolute',
|
| 100 |
+
top: '50%',
|
| 101 |
+
left: '50%',
|
| 102 |
+
transform: 'translate(-50%, -50%)',
|
| 103 |
+
width: '140px',
|
| 104 |
+
height: '140px',
|
| 105 |
+
}}>
|
| 106 |
+
<OppyFace phase={phase} size={140} />
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
);
|
| 110 |
+
}
|
frontend/src/components/OppyFace.jsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
// 4-pointed star path centered at (0,0), size ~1 unit
|
| 4 |
+
const STAR_PATH = 'M 0 -1 C 0.12 -0.12, 0.12 -0.12, 1 0 C 0.12 0.12, 0.12 0.12, 0 1 C -0.12 0.12, -0.12 0.12, -1 0 C -0.12 -0.12, -0.12 -0.12, 0 -1 Z';
|
| 5 |
+
|
| 6 |
+
// Smile mouth: a crescent arc
|
| 7 |
+
const SMILE_PATH = 'M -12 0 C -8 10, 8 10, 12 0 C 8 4, -8 4, -12 0 Z';
|
| 8 |
+
|
| 9 |
+
// Open mouth: rounder, taller
|
| 10 |
+
const OPEN_PATH = 'M -10 -4 C -8 12, 8 12, 10 -4 C 6 6, -6 6, -10 -4 Z';
|
| 11 |
+
|
| 12 |
+
export default function OppyFace({ phase, size = 180 }) {
|
| 13 |
+
const svgRef = useRef(null);
|
| 14 |
+
|
| 15 |
+
// --- Eye tracking ---
|
| 16 |
+
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
| 17 |
+
|
| 18 |
+
const handleMouseMove = useCallback((e) => {
|
| 19 |
+
if (!svgRef.current) return;
|
| 20 |
+
const rect = svgRef.current.getBoundingClientRect();
|
| 21 |
+
const cx = rect.left + rect.width / 2;
|
| 22 |
+
const cy = rect.top + rect.height / 2;
|
| 23 |
+
const dx = e.clientX - cx;
|
| 24 |
+
const dy = e.clientY - cy;
|
| 25 |
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 26 |
+
const max = 5;
|
| 27 |
+
const factor = Math.min(dist / 300, 1);
|
| 28 |
+
setEyeOffset({
|
| 29 |
+
x: (dx / (dist || 1)) * max * factor,
|
| 30 |
+
y: (dy / (dist || 1)) * max * factor,
|
| 31 |
+
});
|
| 32 |
+
}, []);
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
document.addEventListener('mousemove', handleMouseMove);
|
| 36 |
+
return () => document.removeEventListener('mousemove', handleMouseMove);
|
| 37 |
+
}, [handleMouseMove]);
|
| 38 |
+
|
| 39 |
+
// --- Blink ---
|
| 40 |
+
const [blinkScale, setBlinkScale] = useState(1);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (phase !== 'IDLE' && phase !== 'LISTENING') return;
|
| 44 |
+
let timer;
|
| 45 |
+
const doBlink = () => {
|
| 46 |
+
setBlinkScale(0.05);
|
| 47 |
+
setTimeout(() => setBlinkScale(1), 120);
|
| 48 |
+
timer = setTimeout(doBlink, 2500 + Math.random() * 4000);
|
| 49 |
+
};
|
| 50 |
+
timer = setTimeout(doBlink, 2000 + Math.random() * 3000);
|
| 51 |
+
return () => clearTimeout(timer);
|
| 52 |
+
}, [phase]);
|
| 53 |
+
|
| 54 |
+
// --- Speaking mouth toggle ---
|
| 55 |
+
const [mouthOpen, setMouthOpen] = useState(false);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
if (phase !== 'SPEAKING') {
|
| 59 |
+
setMouthOpen(false);
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
const interval = setInterval(() => {
|
| 63 |
+
setMouthOpen(prev => !prev);
|
| 64 |
+
}, 200 + Math.random() * 150);
|
| 65 |
+
return () => clearInterval(interval);
|
| 66 |
+
}, [phase]);
|
| 67 |
+
|
| 68 |
+
// --- Thinking: eyes spin ---
|
| 69 |
+
const thinkRotate = phase === 'THINKING';
|
| 70 |
+
|
| 71 |
+
const mouthPath = mouthOpen ? OPEN_PATH : SMILE_PATH;
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<svg
|
| 75 |
+
ref={svgRef}
|
| 76 |
+
viewBox="0 0 100 100"
|
| 77 |
+
width={size}
|
| 78 |
+
height={size}
|
| 79 |
+
style={{ overflow: 'visible' }}
|
| 80 |
+
>
|
| 81 |
+
<defs>
|
| 82 |
+
{/* Gemini star gradient */}
|
| 83 |
+
<radialGradient id="starGrad" cx="50%" cy="50%" r="60%" fx="35%" fy="30%">
|
| 84 |
+
<stop offset="0%" stopColor="#4285f4" />
|
| 85 |
+
<stop offset="30%" stopColor="#4285f4" />
|
| 86 |
+
<stop offset="50%" stopColor="#34a853" />
|
| 87 |
+
<stop offset="70%" stopColor="#fbbc04" />
|
| 88 |
+
<stop offset="90%" stopColor="#ea4335" />
|
| 89 |
+
</radialGradient>
|
| 90 |
+
|
| 91 |
+
{/* Mouth gradient */}
|
| 92 |
+
<linearGradient id="mouthGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 93 |
+
<stop offset="0%" stopColor="#fbbc04" />
|
| 94 |
+
<stop offset="50%" stopColor="#34a853" />
|
| 95 |
+
<stop offset="100%" stopColor="#4285f4" />
|
| 96 |
+
</linearGradient>
|
| 97 |
+
</defs>
|
| 98 |
+
|
| 99 |
+
{/* Left eye (star) */}
|
| 100 |
+
<g
|
| 101 |
+
transform={`translate(${34 + eyeOffset.x}, ${38 + eyeOffset.y})`}
|
| 102 |
+
style={{
|
| 103 |
+
transition: 'transform 0.12s ease-out',
|
| 104 |
+
}}
|
| 105 |
+
>
|
| 106 |
+
<g
|
| 107 |
+
transform={`scale(14, ${14 * blinkScale})`}
|
| 108 |
+
style={{
|
| 109 |
+
transition: 'transform 0.08s ease-in-out',
|
| 110 |
+
transformOrigin: '0 0',
|
| 111 |
+
}}
|
| 112 |
+
>
|
| 113 |
+
<g style={{
|
| 114 |
+
animation: thinkRotate ? 'eyeSpin 1.5s linear infinite' : 'none',
|
| 115 |
+
transformOrigin: '0 0',
|
| 116 |
+
}}>
|
| 117 |
+
<path d={STAR_PATH} fill="url(#starGrad)" />
|
| 118 |
+
</g>
|
| 119 |
+
</g>
|
| 120 |
+
</g>
|
| 121 |
+
|
| 122 |
+
{/* Right eye (star) */}
|
| 123 |
+
<g
|
| 124 |
+
transform={`translate(${66 + eyeOffset.x}, ${38 + eyeOffset.y})`}
|
| 125 |
+
style={{
|
| 126 |
+
transition: 'transform 0.12s ease-out',
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
<g
|
| 130 |
+
transform={`scale(14, ${14 * blinkScale})`}
|
| 131 |
+
style={{
|
| 132 |
+
transition: 'transform 0.08s ease-in-out',
|
| 133 |
+
transformOrigin: '0 0',
|
| 134 |
+
}}
|
| 135 |
+
>
|
| 136 |
+
<g style={{
|
| 137 |
+
animation: thinkRotate ? 'eyeSpin 1.5s linear infinite reverse' : 'none',
|
| 138 |
+
transformOrigin: '0 0',
|
| 139 |
+
}}>
|
| 140 |
+
<path d={STAR_PATH} fill="url(#starGrad)" />
|
| 141 |
+
</g>
|
| 142 |
+
</g>
|
| 143 |
+
</g>
|
| 144 |
+
|
| 145 |
+
{/* Mouth */}
|
| 146 |
+
<g
|
| 147 |
+
transform="translate(50, 65)"
|
| 148 |
+
style={{
|
| 149 |
+
transition: 'transform 0.15s ease-in-out',
|
| 150 |
+
}}
|
| 151 |
+
>
|
| 152 |
+
<path
|
| 153 |
+
d={mouthPath}
|
| 154 |
+
fill="url(#mouthGrad)"
|
| 155 |
+
style={{
|
| 156 |
+
transition: 'd 0.12s ease-in-out',
|
| 157 |
+
}}
|
| 158 |
+
/>
|
| 159 |
+
</g>
|
| 160 |
+
</svg>
|
| 161 |
+
);
|
| 162 |
+
}
|
frontend/src/components/ProjectCard.jsx
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
const STATUS_CONFIG = {
|
| 6 |
+
STANDBY: { label: 'STANDBY', bg: '#f1f3f4', color: '#5f6368' },
|
| 7 |
+
READY: { label: 'READY', bg: 'rgba(52,168,83,0.1)', color: '#1e8e3e' },
|
| 8 |
+
URGENT: { label: 'URGENT', bg: 'rgba(234,67,53,0.1)', color: '#d93025' },
|
| 9 |
+
SIGNAL: { label: 'SIGNAL', bg: 'rgba(251,188,4,0.1)', color: '#e37400' },
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function formatDate(dateStr) {
|
| 13 |
+
if (!dateStr) return '';
|
| 14 |
+
const d = new Date(dateStr);
|
| 15 |
+
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function formatTime(timeStr) {
|
| 19 |
+
if (!timeStr) return '';
|
| 20 |
+
const d = new Date(timeStr);
|
| 21 |
+
return d.toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export default function ProjectCard({ project, status, alerts }) {
|
| 25 |
+
const st = STATUS_CONFIG[status] || STATUS_CONFIG.STANDBY;
|
| 26 |
+
const [expanded, setExpanded] = useState(false);
|
| 27 |
+
const [sources, setSources] = useState(null);
|
| 28 |
+
const [loading, setLoading] = useState(false);
|
| 29 |
+
|
| 30 |
+
const handleClick = async () => {
|
| 31 |
+
if (expanded) { setExpanded(false); return; }
|
| 32 |
+
if (!sources) {
|
| 33 |
+
setLoading(true);
|
| 34 |
+
try {
|
| 35 |
+
const res = await fetch(`${BACKEND_URL}/api/project/${project.id}/sources`);
|
| 36 |
+
setSources(await res.json());
|
| 37 |
+
} catch (err) { console.error(err); }
|
| 38 |
+
finally { setLoading(false); }
|
| 39 |
+
}
|
| 40 |
+
setExpanded(true);
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div
|
| 45 |
+
onClick={handleClick}
|
| 46 |
+
style={{
|
| 47 |
+
background: '#ffffff',
|
| 48 |
+
border: '1px solid #e0e0e0',
|
| 49 |
+
borderLeft: `4px solid ${project.color}`,
|
| 50 |
+
borderRadius: '12px',
|
| 51 |
+
padding: '16px 20px',
|
| 52 |
+
flex: '1 1 280px',
|
| 53 |
+
minWidth: '0',
|
| 54 |
+
maxWidth: '100%',
|
| 55 |
+
cursor: 'pointer',
|
| 56 |
+
transition: 'box-shadow 0.2s',
|
| 57 |
+
overflow: 'hidden',
|
| 58 |
+
wordBreak: 'break-word',
|
| 59 |
+
}}
|
| 60 |
+
onMouseEnter={(e) => e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'}
|
| 61 |
+
onMouseLeave={(e) => e.currentTarget.style.boxShadow = 'none'}
|
| 62 |
+
>
|
| 63 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
| 64 |
+
<span style={{ fontSize: '11px', color: '#5f6368', letterSpacing: '1px', fontWeight: 500 }}>
|
| 65 |
+
PROJET
|
| 66 |
+
</span>
|
| 67 |
+
<span style={{
|
| 68 |
+
fontSize: '11px', fontWeight: 600, padding: '3px 10px',
|
| 69 |
+
background: st.bg, color: st.color, borderRadius: '10px',
|
| 70 |
+
animation: status === 'URGENT' ? 'pulse 1.5s infinite' : 'none',
|
| 71 |
+
}}>
|
| 72 |
+
{st.label}
|
| 73 |
+
</span>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<h3 style={{ fontSize: '15px', color: '#1f1f1f', marginBottom: '6px', fontWeight: 600 }}>
|
| 77 |
+
{project.name}
|
| 78 |
+
</h3>
|
| 79 |
+
|
| 80 |
+
{project.contact && (
|
| 81 |
+
<p style={{ fontSize: '12px', color: '#5f6368', marginBottom: '10px' }}>{project.contact}</p>
|
| 82 |
+
)}
|
| 83 |
+
|
| 84 |
+
{alerts && alerts.length > 0 && (
|
| 85 |
+
<ul style={{ listStyle: 'none', padding: 0 }}>
|
| 86 |
+
{alerts.map((alert, i) => (
|
| 87 |
+
<li key={i} style={{
|
| 88 |
+
fontSize: '12px', color: st.color, padding: '5px 0',
|
| 89 |
+
borderTop: '1px solid #f1f3f4',
|
| 90 |
+
}}>
|
| 91 |
+
{alert}
|
| 92 |
+
</li>
|
| 93 |
+
))}
|
| 94 |
+
</ul>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{!expanded && (
|
| 98 |
+
<div style={{ fontSize: '11px', color: '#9aa0a6', marginTop: '8px', textAlign: 'center' }}>
|
| 99 |
+
Cliquer pour voir les sources
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{loading && (
|
| 104 |
+
<div style={{ fontSize: '12px', color: '#5f6368', marginTop: '10px', fontStyle: 'italic' }}>
|
| 105 |
+
Chargement...
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
|
| 109 |
+
{expanded && sources && (
|
| 110 |
+
<div onClick={(e) => e.stopPropagation()} style={{
|
| 111 |
+
marginTop: '14px', borderTop: '1px solid #e0e0e0', paddingTop: '14px',
|
| 112 |
+
animation: 'fadeIn 0.3s ease-out',
|
| 113 |
+
}}>
|
| 114 |
+
{/* Emails */}
|
| 115 |
+
{sources.emails && sources.emails.length > 0 && (
|
| 116 |
+
<div style={{ marginBottom: '16px' }}>
|
| 117 |
+
<div style={{ fontSize: '11px', color: project.color, letterSpacing: '1px', marginBottom: '8px', fontWeight: 600 }}>
|
| 118 |
+
EMAILS
|
| 119 |
+
</div>
|
| 120 |
+
{sources.emails.map((email, i) => (
|
| 121 |
+
<div key={i} style={{
|
| 122 |
+
padding: '10px 12px', background: '#f8f9fa', borderRadius: '8px',
|
| 123 |
+
marginBottom: '6px', fontSize: '13px',
|
| 124 |
+
}}>
|
| 125 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
| 126 |
+
<span style={{ color: '#1f1f1f', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>{email.subject}</span>
|
| 127 |
+
<span style={{
|
| 128 |
+
fontSize: '11px',
|
| 129 |
+
color: email.days_since_reply >= 5 ? '#d93025' : '#5f6368',
|
| 130 |
+
fontWeight: email.days_since_reply >= 5 ? 600 : 400,
|
| 131 |
+
}}>
|
| 132 |
+
{email.days_since_reply != null ? `${email.days_since_reply}j` : ''}
|
| 133 |
+
</span>
|
| 134 |
+
</div>
|
| 135 |
+
<div style={{ color: '#5f6368', fontSize: '12px', marginBottom: '6px' }}>
|
| 136 |
+
De : {email.from} · {formatDate(email.date)}
|
| 137 |
+
</div>
|
| 138 |
+
<div style={{ color: '#3c4043', fontSize: '12px', lineHeight: '1.5', marginBottom: '8px' }}>
|
| 139 |
+
{email.body}
|
| 140 |
+
</div>
|
| 141 |
+
<div style={{ display: 'flex', gap: '8px' }}>
|
| 142 |
+
<a
|
| 143 |
+
href={`mailto:${email.from}?subject=Re: ${email.subject}`}
|
| 144 |
+
style={{
|
| 145 |
+
fontSize: '11px', color: '#4285f4', textDecoration: 'none',
|
| 146 |
+
padding: '4px 10px', border: '1px solid #4285f4',
|
| 147 |
+
borderRadius: '14px', fontWeight: 500,
|
| 148 |
+
}}
|
| 149 |
+
>
|
| 150 |
+
Repondre
|
| 151 |
+
</a>
|
| 152 |
+
<a
|
| 153 |
+
href={`https://mail.google.com/mail/u/0/#search/from:${email.from}+subject:${encodeURIComponent(email.subject)}`}
|
| 154 |
+
target="_blank" rel="noopener noreferrer"
|
| 155 |
+
style={{
|
| 156 |
+
fontSize: '11px', color: '#5f6368', textDecoration: 'none',
|
| 157 |
+
padding: '4px 10px', border: '1px solid #e0e0e0',
|
| 158 |
+
borderRadius: '14px',
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
Ouvrir dans Gmail
|
| 162 |
+
</a>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
))}
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
|
| 169 |
+
{/* Events */}
|
| 170 |
+
{sources.events && sources.events.length > 0 && (
|
| 171 |
+
<div style={{ marginBottom: '16px' }}>
|
| 172 |
+
<div style={{ fontSize: '11px', color: project.color, letterSpacing: '1px', marginBottom: '8px', fontWeight: 600 }}>
|
| 173 |
+
CALENDRIER
|
| 174 |
+
</div>
|
| 175 |
+
{sources.events.map((event, i) => (
|
| 176 |
+
<div key={i} style={{
|
| 177 |
+
padding: '10px 12px', background: '#f8f9fa', borderRadius: '8px',
|
| 178 |
+
marginBottom: '6px', fontSize: '13px',
|
| 179 |
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
| 180 |
+
}}>
|
| 181 |
+
<div>
|
| 182 |
+
<div style={{ color: '#1f1f1f', fontWeight: 600 }}>{event.title}</div>
|
| 183 |
+
<div style={{ color: '#5f6368', fontSize: '12px', marginTop: '2px' }}>
|
| 184 |
+
{formatTime(event.time)}
|
| 185 |
+
{event.prep_block
|
| 186 |
+
? <span style={{ color: '#1e8e3e', marginLeft: '8px', fontWeight: 500 }}>Prep OK</span>
|
| 187 |
+
: <span style={{ color: '#d93025', marginLeft: '8px', fontWeight: 500 }}>Pas de prep</span>
|
| 188 |
+
}
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
<a
|
| 192 |
+
href={`https://calendar.google.com/calendar/r/search?q=${encodeURIComponent(event.title)}`}
|
| 193 |
+
target="_blank" rel="noopener noreferrer"
|
| 194 |
+
style={{
|
| 195 |
+
fontSize: '11px', color: '#4285f4', textDecoration: 'none',
|
| 196 |
+
padding: '4px 10px', border: '1px solid #4285f4',
|
| 197 |
+
borderRadius: '14px', fontWeight: 500, flexShrink: 0,
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
Ouvrir l'agenda
|
| 201 |
+
</a>
|
| 202 |
+
</div>
|
| 203 |
+
))}
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{/* Web signal */}
|
| 208 |
+
{sources.search && (
|
| 209 |
+
<div>
|
| 210 |
+
<div style={{ fontSize: '11px', color: project.color, letterSpacing: '1px', marginBottom: '8px', fontWeight: 600 }}>
|
| 211 |
+
SIGNAL EXTERNE
|
| 212 |
+
</div>
|
| 213 |
+
<div style={{
|
| 214 |
+
padding: '10px 12px', background: '#f8f9fa', borderRadius: '8px',
|
| 215 |
+
fontSize: '12px', color: '#3c4043', lineHeight: '1.5',
|
| 216 |
+
}}>
|
| 217 |
+
{sources.search}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
<div style={{ fontSize: '11px', color: '#9aa0a6', marginTop: '12px', textAlign: 'center' }}>
|
| 223 |
+
Cliquer pour fermer
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
</div>
|
| 228 |
+
);
|
| 229 |
+
}
|
frontend/src/components/ProjectCardsDrawer.jsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import ProjectCard from './ProjectCard';
|
| 3 |
+
|
| 4 |
+
export default function ProjectCardsDrawer({ projects, statuses }) {
|
| 5 |
+
const [open, setOpen] = useState(false);
|
| 6 |
+
|
| 7 |
+
return (
|
| 8 |
+
<div style={{
|
| 9 |
+
position: 'fixed',
|
| 10 |
+
bottom: 0,
|
| 11 |
+
left: 0,
|
| 12 |
+
right: 0,
|
| 13 |
+
zIndex: 20,
|
| 14 |
+
}}>
|
| 15 |
+
{/* Handle bar */}
|
| 16 |
+
<div
|
| 17 |
+
onClick={() => setOpen(!open)}
|
| 18 |
+
style={{
|
| 19 |
+
display: 'flex',
|
| 20 |
+
alignItems: 'center',
|
| 21 |
+
justifyContent: 'center',
|
| 22 |
+
gap: '10px',
|
| 23 |
+
padding: '10px',
|
| 24 |
+
background: '#ffffff',
|
| 25 |
+
borderTop: '1px solid #e0e0e0',
|
| 26 |
+
cursor: 'pointer',
|
| 27 |
+
}}
|
| 28 |
+
>
|
| 29 |
+
<span style={{
|
| 30 |
+
fontSize: '13px',
|
| 31 |
+
color: '#1f1f1f',
|
| 32 |
+
fontWeight: 500,
|
| 33 |
+
}}>
|
| 34 |
+
{open ? 'Fermer les projets' : `${projects.length} projets`}
|
| 35 |
+
</span>
|
| 36 |
+
<span style={{ fontSize: '10px', color: '#5f6368' }}>
|
| 37 |
+
{open ? '\u25BC' : '\u25B2'}
|
| 38 |
+
</span>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
{/* Drawer content */}
|
| 42 |
+
{open && (
|
| 43 |
+
<div style={{
|
| 44 |
+
background: '#f8f9fa',
|
| 45 |
+
borderTop: '1px solid #e0e0e0',
|
| 46 |
+
padding: '16px 24px',
|
| 47 |
+
display: 'flex',
|
| 48 |
+
flexWrap: 'wrap',
|
| 49 |
+
gap: '12px',
|
| 50 |
+
maxHeight: '55vh',
|
| 51 |
+
overflowY: 'auto',
|
| 52 |
+
animation: 'fadeIn 0.2s ease-out',
|
| 53 |
+
}}>
|
| 54 |
+
{projects.map(project => (
|
| 55 |
+
<ProjectCard
|
| 56 |
+
key={project.id}
|
| 57 |
+
project={project}
|
| 58 |
+
status={statuses[project.id]?.status || 'STANDBY'}
|
| 59 |
+
alerts={statuses[project.id]?.alerts || []}
|
| 60 |
+
/>
|
| 61 |
+
))}
|
| 62 |
+
</div>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
frontend/src/components/ToolFeed.jsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const STATUS_ICONS = {
|
| 2 |
+
running: '\u25CF',
|
| 3 |
+
done: '\u2713',
|
| 4 |
+
waiting: '\u25CB',
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
const PROJECT_COLORS = {
|
| 8 |
+
school: '#00FF88',
|
| 9 |
+
company: '#FF4444',
|
| 10 |
+
startup: '#FF8800',
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export default function ToolFeed({ toolCalls }) {
|
| 14 |
+
if (!toolCalls || toolCalls.length === 0) {
|
| 15 |
+
return (
|
| 16 |
+
<div style={{
|
| 17 |
+
background: 'var(--card-bg)',
|
| 18 |
+
padding: '20px',
|
| 19 |
+
borderTop: '1px solid var(--border)',
|
| 20 |
+
}}>
|
| 21 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '8px' }}>
|
| 22 |
+
TOOL FEED
|
| 23 |
+
</h4>
|
| 24 |
+
<p style={{ fontSize: '12px', color: 'var(--text-dim)' }}>
|
| 25 |
+
En attente du scan...
|
| 26 |
+
</p>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div style={{
|
| 33 |
+
background: 'var(--card-bg)',
|
| 34 |
+
padding: '20px',
|
| 35 |
+
borderTop: '1px solid var(--border)',
|
| 36 |
+
maxHeight: '220px',
|
| 37 |
+
overflowY: 'auto',
|
| 38 |
+
}}>
|
| 39 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '12px' }}>
|
| 40 |
+
TOOL FEED
|
| 41 |
+
</h4>
|
| 42 |
+
{toolCalls.map((tc, i) => (
|
| 43 |
+
<div
|
| 44 |
+
key={i}
|
| 45 |
+
style={{
|
| 46 |
+
display: 'flex',
|
| 47 |
+
alignItems: 'center',
|
| 48 |
+
gap: '12px',
|
| 49 |
+
padding: '6px 0',
|
| 50 |
+
fontSize: '13px',
|
| 51 |
+
animation: 'slideIn 0.3s ease-out',
|
| 52 |
+
animationDelay: `${i * 0.1}s`,
|
| 53 |
+
animationFillMode: 'both',
|
| 54 |
+
}}
|
| 55 |
+
>
|
| 56 |
+
<span style={{
|
| 57 |
+
color: tc.status === 'done' ? '#00FF88' : tc.status === 'running' ? '#FF8800' : '#666',
|
| 58 |
+
minWidth: '14px',
|
| 59 |
+
}}>
|
| 60 |
+
{STATUS_ICONS[tc.status] || STATUS_ICONS.waiting}
|
| 61 |
+
</span>
|
| 62 |
+
<span style={{ color: '#fff', minWidth: '180px', fontSize: '12px' }}>
|
| 63 |
+
{tc.tool}(<span style={{ color: PROJECT_COLORS[tc.project] || '#888' }}>{tc.project}</span>)
|
| 64 |
+
</span>
|
| 65 |
+
<div style={{
|
| 66 |
+
flex: 1,
|
| 67 |
+
height: '4px',
|
| 68 |
+
background: '#222',
|
| 69 |
+
borderRadius: '2px',
|
| 70 |
+
overflow: 'hidden',
|
| 71 |
+
}}>
|
| 72 |
+
<div style={{
|
| 73 |
+
width: tc.status === 'done' ? '100%' : tc.status === 'running' ? '60%' : '0%',
|
| 74 |
+
height: '100%',
|
| 75 |
+
background: PROJECT_COLORS[tc.project] || 'var(--green)',
|
| 76 |
+
transition: 'width 0.5s ease',
|
| 77 |
+
}} />
|
| 78 |
+
</div>
|
| 79 |
+
<span style={{ fontSize: '11px', color: 'var(--text-dim)', minWidth: '40px', textAlign: 'right' }}>
|
| 80 |
+
{tc.status === 'done' ? 'done' : tc.status === 'running' ? '...' : 'wait'}
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
))}
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
frontend/src/components/VoiceControl.jsx
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
export default function VoiceControl({ onReply, chatMessages, visible }) {
|
| 6 |
+
const [isListening, setIsListening] = useState(false);
|
| 7 |
+
const [transcript, setTranscript] = useState('');
|
| 8 |
+
const [inputText, setInputText] = useState('');
|
| 9 |
+
const [isThinking, setIsThinking] = useState(false);
|
| 10 |
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
| 11 |
+
const recognitionRef = useRef(null);
|
| 12 |
+
const audioRef = useRef(null);
|
| 13 |
+
const chatEndRef = useRef(null);
|
| 14 |
+
const inputRef = useRef(null);
|
| 15 |
+
const hasSpeechAPI = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
| 16 |
+
|
| 17 |
+
// Scroll to bottom when new messages arrive
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 20 |
+
}, [chatMessages, isThinking]);
|
| 21 |
+
|
| 22 |
+
// Focus input when panel becomes visible
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (visible) {
|
| 25 |
+
setTimeout(() => inputRef.current?.focus(), 300);
|
| 26 |
+
}
|
| 27 |
+
}, [visible]);
|
| 28 |
+
|
| 29 |
+
// Setup speech recognition
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 32 |
+
if (!SpeechRecognition) return;
|
| 33 |
+
|
| 34 |
+
const recognition = new SpeechRecognition();
|
| 35 |
+
recognition.lang = 'fr-FR';
|
| 36 |
+
recognition.continuous = false;
|
| 37 |
+
recognition.interimResults = true;
|
| 38 |
+
|
| 39 |
+
recognition.onresult = (event) => {
|
| 40 |
+
let interim = '';
|
| 41 |
+
let final = '';
|
| 42 |
+
for (let i = 0; i < event.results.length; i++) {
|
| 43 |
+
if (event.results[i].isFinal) {
|
| 44 |
+
final += event.results[i][0].transcript;
|
| 45 |
+
} else {
|
| 46 |
+
interim += event.results[i][0].transcript;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
const text = final || interim;
|
| 50 |
+
setTranscript(text);
|
| 51 |
+
setInputText(text);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
recognition.onend = () => {
|
| 55 |
+
setIsListening(false);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
recognition.onerror = (event) => {
|
| 59 |
+
console.error('STT error:', event.error);
|
| 60 |
+
setIsListening(false);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
recognitionRef.current = recognition;
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
// Auto-send when recognition ends with a final transcript
|
| 67 |
+
const pendingSendRef = useRef(false);
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (!isListening && pendingSendRef.current && transcript.trim() && !isThinking) {
|
| 70 |
+
pendingSendRef.current = false;
|
| 71 |
+
sendMessage(transcript.trim());
|
| 72 |
+
}
|
| 73 |
+
}, [isListening]);
|
| 74 |
+
|
| 75 |
+
const toggleMic = () => {
|
| 76 |
+
if (isListening) {
|
| 77 |
+
pendingSendRef.current = true;
|
| 78 |
+
recognitionRef.current?.stop();
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
setTranscript('');
|
| 82 |
+
setInputText('');
|
| 83 |
+
pendingSendRef.current = true;
|
| 84 |
+
recognitionRef.current?.start();
|
| 85 |
+
setIsListening(true);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleSubmit = (e) => {
|
| 89 |
+
e.preventDefault();
|
| 90 |
+
const text = inputText.trim();
|
| 91 |
+
if (!text || isThinking) return;
|
| 92 |
+
sendMessage(text);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const sendMessage = async (text) => {
|
| 96 |
+
setIsThinking(true);
|
| 97 |
+
setInputText('');
|
| 98 |
+
setTranscript('');
|
| 99 |
+
onReply?.({ role: 'user', text });
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
const res = await fetch(`${BACKEND_URL}/api/chat`, {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
headers: { 'Content-Type': 'application/json' },
|
| 105 |
+
body: JSON.stringify({ message: text }),
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
// Read SSE stream for chat reply
|
| 109 |
+
const reader = res.body.getReader();
|
| 110 |
+
const decoder = new TextDecoder();
|
| 111 |
+
let replyText = '';
|
| 112 |
+
let buffer = '';
|
| 113 |
+
|
| 114 |
+
while (true) {
|
| 115 |
+
const { done, value } = await reader.read();
|
| 116 |
+
if (done) break;
|
| 117 |
+
buffer += decoder.decode(value, { stream: true });
|
| 118 |
+
|
| 119 |
+
const lines = buffer.split('\n');
|
| 120 |
+
buffer = lines.pop() || '';
|
| 121 |
+
|
| 122 |
+
for (const line of lines) {
|
| 123 |
+
if (line.startsWith('data:') || line.startsWith('data: ')) {
|
| 124 |
+
try {
|
| 125 |
+
const jsonStr = line.replace(/^data:\s*/, '');
|
| 126 |
+
const data = JSON.parse(jsonStr);
|
| 127 |
+
if (data.type === 'chat_reply') {
|
| 128 |
+
replyText = data.text;
|
| 129 |
+
}
|
| 130 |
+
} catch { /* skip non-JSON lines */ }
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (replyText) {
|
| 136 |
+
onReply?.({ role: 'assistant', text: replyText });
|
| 137 |
+
await speakReply(replyText);
|
| 138 |
+
}
|
| 139 |
+
} catch (err) {
|
| 140 |
+
console.error('Chat error:', err);
|
| 141 |
+
onReply?.({ role: 'assistant', text: 'Erreur de connexion. Reessaie.' });
|
| 142 |
+
} finally {
|
| 143 |
+
setIsThinking(false);
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const speakReply = async (text) => {
|
| 148 |
+
try {
|
| 149 |
+
setIsSpeaking(true);
|
| 150 |
+
const res = await fetch(`${BACKEND_URL}/api/tts`, {
|
| 151 |
+
method: 'POST',
|
| 152 |
+
headers: { 'Content-Type': 'application/json' },
|
| 153 |
+
body: JSON.stringify({ text }),
|
| 154 |
+
});
|
| 155 |
+
if (!res.ok) { setIsSpeaking(false); return; }
|
| 156 |
+
|
| 157 |
+
const blob = await res.blob();
|
| 158 |
+
const url = URL.createObjectURL(blob);
|
| 159 |
+
const audio = new Audio(url);
|
| 160 |
+
audioRef.current = audio;
|
| 161 |
+
|
| 162 |
+
audio.onended = () => {
|
| 163 |
+
setIsSpeaking(false);
|
| 164 |
+
URL.revokeObjectURL(url);
|
| 165 |
+
};
|
| 166 |
+
await audio.play();
|
| 167 |
+
} catch (err) {
|
| 168 |
+
console.error('TTS error:', err);
|
| 169 |
+
setIsSpeaking(false);
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
if (!visible) return null;
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<div style={{
|
| 177 |
+
background: 'var(--card-bg)',
|
| 178 |
+
borderTop: '2px solid var(--green)',
|
| 179 |
+
marginTop: '8px',
|
| 180 |
+
animation: 'fadeIn 0.4s ease-out',
|
| 181 |
+
}}>
|
| 182 |
+
{/* Header */}
|
| 183 |
+
<div style={{
|
| 184 |
+
padding: '16px 24px 0',
|
| 185 |
+
display: 'flex',
|
| 186 |
+
alignItems: 'center',
|
| 187 |
+
gap: '8px',
|
| 188 |
+
}}>
|
| 189 |
+
<span style={{ color: 'var(--green)', fontSize: '14px' }}>▶</span>
|
| 190 |
+
<h4 style={{
|
| 191 |
+
fontSize: '11px',
|
| 192 |
+
color: 'var(--green)',
|
| 193 |
+
letterSpacing: '2px',
|
| 194 |
+
margin: 0,
|
| 195 |
+
}}>
|
| 196 |
+
CONVERSATION AVEC OPERATOR
|
| 197 |
+
</h4>
|
| 198 |
+
{isSpeaking && (
|
| 199 |
+
<span style={{
|
| 200 |
+
fontSize: '10px',
|
| 201 |
+
color: '#6666ff',
|
| 202 |
+
letterSpacing: '1px',
|
| 203 |
+
marginLeft: 'auto',
|
| 204 |
+
animation: 'pulse 1s infinite',
|
| 205 |
+
}}>
|
| 206 |
+
PARLE...
|
| 207 |
+
</span>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Chat messages */}
|
| 212 |
+
<div style={{
|
| 213 |
+
maxHeight: '300px',
|
| 214 |
+
overflowY: 'auto',
|
| 215 |
+
padding: '12px 24px',
|
| 216 |
+
display: 'flex',
|
| 217 |
+
flexDirection: 'column',
|
| 218 |
+
gap: '10px',
|
| 219 |
+
}}>
|
| 220 |
+
{(!chatMessages || chatMessages.length === 0) && !isThinking && (
|
| 221 |
+
<p style={{
|
| 222 |
+
fontSize: '13px',
|
| 223 |
+
color: 'var(--text-dim)',
|
| 224 |
+
fontStyle: 'italic',
|
| 225 |
+
margin: '8px 0',
|
| 226 |
+
}}>
|
| 227 |
+
Dis-moi par quoi tu veux commencer, ou pose-moi une question sur tes projets.
|
| 228 |
+
</p>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{chatMessages && chatMessages.map((msg, i) => (
|
| 232 |
+
<div key={i} style={{
|
| 233 |
+
padding: '10px 14px',
|
| 234 |
+
borderRadius: '4px',
|
| 235 |
+
fontSize: '13px',
|
| 236 |
+
lineHeight: '1.6',
|
| 237 |
+
whiteSpace: 'pre-wrap',
|
| 238 |
+
background: msg.role === 'user' ? '#1a2a1a' : '#1a1a2a',
|
| 239 |
+
borderLeft: `3px solid ${msg.role === 'user' ? 'var(--green)' : '#6666ff'}`,
|
| 240 |
+
color: '#e0e0e0',
|
| 241 |
+
animation: 'slideIn 0.3s ease-out',
|
| 242 |
+
}}>
|
| 243 |
+
<span style={{
|
| 244 |
+
fontSize: '10px',
|
| 245 |
+
color: msg.role === 'user' ? 'var(--green)' : '#6666ff',
|
| 246 |
+
letterSpacing: '1px',
|
| 247 |
+
fontWeight: 700,
|
| 248 |
+
display: 'block',
|
| 249 |
+
marginBottom: '4px',
|
| 250 |
+
}}>
|
| 251 |
+
{msg.role === 'user' ? 'TOI' : 'OPERATOR'}
|
| 252 |
+
</span>
|
| 253 |
+
{msg.text}
|
| 254 |
+
</div>
|
| 255 |
+
))}
|
| 256 |
+
|
| 257 |
+
{isThinking && (
|
| 258 |
+
<div style={{
|
| 259 |
+
padding: '10px 14px',
|
| 260 |
+
fontSize: '13px',
|
| 261 |
+
color: '#6666ff',
|
| 262 |
+
fontStyle: 'italic',
|
| 263 |
+
animation: 'pulse 1.5s infinite',
|
| 264 |
+
}}>
|
| 265 |
+
Operator reflechit...
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
+
<div ref={chatEndRef} />
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
{/* Input bar */}
|
| 273 |
+
<form
|
| 274 |
+
onSubmit={handleSubmit}
|
| 275 |
+
style={{
|
| 276 |
+
display: 'flex',
|
| 277 |
+
alignItems: 'center',
|
| 278 |
+
gap: '10px',
|
| 279 |
+
padding: '12px 24px 16px',
|
| 280 |
+
borderTop: '1px solid var(--border)',
|
| 281 |
+
}}
|
| 282 |
+
>
|
| 283 |
+
{hasSpeechAPI && (
|
| 284 |
+
<button
|
| 285 |
+
type="button"
|
| 286 |
+
onClick={toggleMic}
|
| 287 |
+
disabled={isThinking || isSpeaking}
|
| 288 |
+
style={{
|
| 289 |
+
width: '40px',
|
| 290 |
+
height: '40px',
|
| 291 |
+
borderRadius: '50%',
|
| 292 |
+
border: `2px solid ${isListening ? 'var(--red)' : '#444'}`,
|
| 293 |
+
background: isListening ? 'rgba(255, 68, 68, 0.15)' : 'transparent',
|
| 294 |
+
color: isListening ? 'var(--red)' : '#888',
|
| 295 |
+
fontSize: '16px',
|
| 296 |
+
cursor: isThinking || isSpeaking ? 'not-allowed' : 'pointer',
|
| 297 |
+
transition: 'all 0.2s',
|
| 298 |
+
opacity: isThinking || isSpeaking ? 0.4 : 1,
|
| 299 |
+
animation: isListening ? 'pulse 1s infinite' : 'none',
|
| 300 |
+
flexShrink: 0,
|
| 301 |
+
}}
|
| 302 |
+
>
|
| 303 |
+
{'\uD83C\uDF99'}
|
| 304 |
+
</button>
|
| 305 |
+
)}
|
| 306 |
+
|
| 307 |
+
<input
|
| 308 |
+
ref={inputRef}
|
| 309 |
+
type="text"
|
| 310 |
+
value={inputText}
|
| 311 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 312 |
+
disabled={isThinking || isSpeaking}
|
| 313 |
+
placeholder={isListening ? 'Ecoute en cours...' : 'Ecris ta question ou parle...'}
|
| 314 |
+
style={{
|
| 315 |
+
flex: 1,
|
| 316 |
+
background: '#1a1a1a',
|
| 317 |
+
border: '1px solid #333',
|
| 318 |
+
borderRadius: '4px',
|
| 319 |
+
padding: '10px 14px',
|
| 320 |
+
color: '#e0e0e0',
|
| 321 |
+
fontFamily: 'inherit',
|
| 322 |
+
fontSize: '13px',
|
| 323 |
+
outline: 'none',
|
| 324 |
+
}}
|
| 325 |
+
/>
|
| 326 |
+
|
| 327 |
+
<button
|
| 328 |
+
type="submit"
|
| 329 |
+
disabled={!inputText.trim() || isThinking || isSpeaking}
|
| 330 |
+
style={{
|
| 331 |
+
background: inputText.trim() && !isThinking ? 'var(--green)' : '#333',
|
| 332 |
+
color: '#0a0a0a',
|
| 333 |
+
border: 'none',
|
| 334 |
+
borderRadius: '4px',
|
| 335 |
+
padding: '10px 18px',
|
| 336 |
+
fontFamily: 'inherit',
|
| 337 |
+
fontSize: '12px',
|
| 338 |
+
fontWeight: 700,
|
| 339 |
+
letterSpacing: '1px',
|
| 340 |
+
cursor: !inputText.trim() || isThinking ? 'not-allowed' : 'pointer',
|
| 341 |
+
transition: 'all 0.2s',
|
| 342 |
+
flexShrink: 0,
|
| 343 |
+
}}
|
| 344 |
+
>
|
| 345 |
+
ENVOYER
|
| 346 |
+
</button>
|
| 347 |
+
</form>
|
| 348 |
+
</div>
|
| 349 |
+
);
|
| 350 |
+
}
|
frontend/src/hooks/useGoogleAuth.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
| 4 |
+
|
| 5 |
+
export default function useGoogleAuth() {
|
| 6 |
+
const [authenticated, setAuthenticated] = useState(false);
|
| 7 |
+
const [oauthAvailable, setOauthAvailable] = useState(false);
|
| 8 |
+
const [loading, setLoading] = useState(true);
|
| 9 |
+
|
| 10 |
+
const checkStatus = useCallback(async () => {
|
| 11 |
+
try {
|
| 12 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/status`);
|
| 13 |
+
const data = await res.json();
|
| 14 |
+
setAuthenticated(data.authenticated);
|
| 15 |
+
setOauthAvailable(data.oauth_available);
|
| 16 |
+
} catch {
|
| 17 |
+
setAuthenticated(false);
|
| 18 |
+
setOauthAvailable(false);
|
| 19 |
+
} finally {
|
| 20 |
+
setLoading(false);
|
| 21 |
+
}
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
checkStatus();
|
| 26 |
+
|
| 27 |
+
// Check if we just came back from OAuth callback
|
| 28 |
+
const params = new URLSearchParams(window.location.search);
|
| 29 |
+
if (params.get('auth') === 'success') {
|
| 30 |
+
// Clean URL and recheck
|
| 31 |
+
window.history.replaceState({}, '', window.location.pathname);
|
| 32 |
+
checkStatus();
|
| 33 |
+
}
|
| 34 |
+
}, [checkStatus]);
|
| 35 |
+
|
| 36 |
+
const login = useCallback(() => {
|
| 37 |
+
window.location.href = `${BACKEND_URL}/api/auth/login`;
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
const logout = useCallback(async () => {
|
| 41 |
+
await fetch(`${BACKEND_URL}/api/auth/logout`);
|
| 42 |
+
setAuthenticated(false);
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
return { authenticated, oauthAvailable, loading, login, logout };
|
| 46 |
+
}
|
frontend/src/hooks/useOperatorSSE.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
export default function useOperatorSSE() {
|
| 6 |
+
const [toolCalls, setToolCalls] = useState([]);
|
| 7 |
+
const [projectStatuses, setProjectStatuses] = useState({});
|
| 8 |
+
const [briefText, setBriefText] = useState(null);
|
| 9 |
+
const [isScanning, setIsScanning] = useState(false);
|
| 10 |
+
const [isLive, setIsLive] = useState(false);
|
| 11 |
+
|
| 12 |
+
const startScan = useCallback(() => {
|
| 13 |
+
setToolCalls([]);
|
| 14 |
+
setProjectStatuses({});
|
| 15 |
+
setBriefText(null);
|
| 16 |
+
setIsScanning(true);
|
| 17 |
+
setIsLive(false);
|
| 18 |
+
|
| 19 |
+
const source = new EventSource(`${BACKEND_URL}/api/run`);
|
| 20 |
+
|
| 21 |
+
source.addEventListener('mode', (e) => {
|
| 22 |
+
const data = JSON.parse(e.data);
|
| 23 |
+
setIsLive(data.live);
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
source.addEventListener('tool_call', (e) => {
|
| 27 |
+
const data = JSON.parse(e.data);
|
| 28 |
+
setToolCalls(prev => [...prev, { tool: data.tool, project: data.project, status: 'running' }]);
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
source.addEventListener('tool_result', (e) => {
|
| 32 |
+
const data = JSON.parse(e.data);
|
| 33 |
+
setToolCalls(prev => {
|
| 34 |
+
const updated = [...prev];
|
| 35 |
+
const idx = updated.findLastIndex(t => t.tool === data.tool && t.project === data.project);
|
| 36 |
+
if (idx >= 0) updated[idx] = { ...updated[idx], status: 'done' };
|
| 37 |
+
return updated;
|
| 38 |
+
});
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
source.addEventListener('initiative', (e) => {
|
| 42 |
+
const data = JSON.parse(e.data);
|
| 43 |
+
setProjectStatuses(prev => ({ ...prev, [data.project]: data }));
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
source.addEventListener('brief', (e) => {
|
| 47 |
+
const data = JSON.parse(e.data);
|
| 48 |
+
setBriefText(data.text);
|
| 49 |
+
setIsScanning(false);
|
| 50 |
+
source.close();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
source.onerror = () => {
|
| 54 |
+
setIsScanning(false);
|
| 55 |
+
source.close();
|
| 56 |
+
};
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
const sendChat = useCallback(async (message) => {
|
| 60 |
+
const res = await fetch(`${BACKEND_URL}/api/chat`, {
|
| 61 |
+
method: 'POST',
|
| 62 |
+
headers: { 'Content-Type': 'application/json' },
|
| 63 |
+
body: JSON.stringify({ message }),
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const reader = res.body.getReader();
|
| 67 |
+
const decoder = new TextDecoder();
|
| 68 |
+
let replyText = '';
|
| 69 |
+
let buffer = '';
|
| 70 |
+
|
| 71 |
+
while (true) {
|
| 72 |
+
const { done, value } = await reader.read();
|
| 73 |
+
if (done) break;
|
| 74 |
+
buffer += decoder.decode(value, { stream: true });
|
| 75 |
+
|
| 76 |
+
const lines = buffer.split('\n');
|
| 77 |
+
buffer = lines.pop() || '';
|
| 78 |
+
|
| 79 |
+
for (const line of lines) {
|
| 80 |
+
if (line.startsWith('data:') || line.startsWith('data: ')) {
|
| 81 |
+
try {
|
| 82 |
+
const data = JSON.parse(line.replace(/^data:\s*/, ''));
|
| 83 |
+
if (data.type === 'chat_reply') {
|
| 84 |
+
replyText = data.text;
|
| 85 |
+
}
|
| 86 |
+
} catch { /* skip */ }
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return replyText;
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
return { toolCalls, projectStatuses, briefText, isScanning, isLive, startScan, sendChat };
|
| 95 |
+
}
|
frontend/src/hooks/useSpeechRecognition.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
export default function useSpeechRecognition({ enabled, onResult, onTimeout, timeoutMs = 15000 }) {
|
| 4 |
+
const [transcript, setTranscript] = useState('');
|
| 5 |
+
const recognitionRef = useRef(null);
|
| 6 |
+
const timeoutRef = useRef(null);
|
| 7 |
+
const enabledRef = useRef(enabled);
|
| 8 |
+
const busyRef = useRef(false); // prevent double-fire of onResult
|
| 9 |
+
enabledRef.current = enabled;
|
| 10 |
+
|
| 11 |
+
const cleanup = useCallback(() => {
|
| 12 |
+
if (timeoutRef.current) {
|
| 13 |
+
clearTimeout(timeoutRef.current);
|
| 14 |
+
timeoutRef.current = null;
|
| 15 |
+
}
|
| 16 |
+
if (recognitionRef.current) {
|
| 17 |
+
try { recognitionRef.current.abort(); } catch {}
|
| 18 |
+
try { recognitionRef.current.stop(); } catch {}
|
| 19 |
+
recognitionRef.current = null;
|
| 20 |
+
}
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
const start = useCallback(() => {
|
| 24 |
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 25 |
+
if (!SpeechRecognition || !enabledRef.current) return;
|
| 26 |
+
|
| 27 |
+
// Full cleanup before starting fresh
|
| 28 |
+
cleanup();
|
| 29 |
+
busyRef.current = false;
|
| 30 |
+
setTranscript('');
|
| 31 |
+
|
| 32 |
+
const recognition = new SpeechRecognition();
|
| 33 |
+
recognition.lang = 'fr-FR';
|
| 34 |
+
recognition.continuous = false;
|
| 35 |
+
recognition.interimResults = true;
|
| 36 |
+
|
| 37 |
+
let finalText = '';
|
| 38 |
+
|
| 39 |
+
recognition.onresult = (event) => {
|
| 40 |
+
// Reset timeout on every result
|
| 41 |
+
if (timeoutRef.current) {
|
| 42 |
+
clearTimeout(timeoutRef.current);
|
| 43 |
+
timeoutRef.current = null;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
let interim = '';
|
| 47 |
+
finalText = '';
|
| 48 |
+
for (let i = 0; i < event.results.length; i++) {
|
| 49 |
+
if (event.results[i].isFinal) {
|
| 50 |
+
finalText += event.results[i][0].transcript;
|
| 51 |
+
} else {
|
| 52 |
+
interim += event.results[i][0].transcript;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
setTranscript(finalText || interim);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
recognition.onend = () => {
|
| 59 |
+
recognitionRef.current = null;
|
| 60 |
+
if (timeoutRef.current) {
|
| 61 |
+
clearTimeout(timeoutRef.current);
|
| 62 |
+
timeoutRef.current = null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const text = finalText.trim();
|
| 66 |
+
if (text && !busyRef.current) {
|
| 67 |
+
busyRef.current = true;
|
| 68 |
+
onResult(text);
|
| 69 |
+
// Don't restart — the phase will change and re-enable us later
|
| 70 |
+
} else if (enabledRef.current && !busyRef.current) {
|
| 71 |
+
// No speech — restart after a pause
|
| 72 |
+
setTimeout(() => {
|
| 73 |
+
if (enabledRef.current) start();
|
| 74 |
+
}, 500);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
recognition.onerror = (event) => {
|
| 79 |
+
recognitionRef.current = null;
|
| 80 |
+
if (event.error === 'no-speech' || event.error === 'aborted') {
|
| 81 |
+
if (enabledRef.current && !busyRef.current) {
|
| 82 |
+
setTimeout(() => {
|
| 83 |
+
if (enabledRef.current) start();
|
| 84 |
+
}, 500);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
recognitionRef.current = recognition;
|
| 90 |
+
|
| 91 |
+
// Silence timeout
|
| 92 |
+
timeoutRef.current = setTimeout(() => {
|
| 93 |
+
cleanup();
|
| 94 |
+
onTimeout?.();
|
| 95 |
+
}, timeoutMs);
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
recognition.start();
|
| 99 |
+
} catch {
|
| 100 |
+
recognitionRef.current = null;
|
| 101 |
+
}
|
| 102 |
+
}, [cleanup, onResult, onTimeout, timeoutMs]);
|
| 103 |
+
|
| 104 |
+
useEffect(() => {
|
| 105 |
+
if (enabled) {
|
| 106 |
+
// Delay start to let browser release previous mic session
|
| 107 |
+
const timer = setTimeout(() => start(), 300);
|
| 108 |
+
return () => {
|
| 109 |
+
clearTimeout(timer);
|
| 110 |
+
cleanup();
|
| 111 |
+
busyRef.current = false;
|
| 112 |
+
};
|
| 113 |
+
} else {
|
| 114 |
+
cleanup();
|
| 115 |
+
busyRef.current = false;
|
| 116 |
+
setTranscript('');
|
| 117 |
+
}
|
| 118 |
+
}, [enabled, start, cleanup]);
|
| 119 |
+
|
| 120 |
+
return { transcript };
|
| 121 |
+
}
|
frontend/src/hooks/useTTS.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
export default function useTTS() {
|
| 6 |
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
| 7 |
+
const audioRef = useRef(null);
|
| 8 |
+
|
| 9 |
+
const speak = useCallback(async (text) => {
|
| 10 |
+
if (!text) return;
|
| 11 |
+
setIsSpeaking(true);
|
| 12 |
+
try {
|
| 13 |
+
const res = await fetch(`${BACKEND_URL}/api/tts`, {
|
| 14 |
+
method: 'POST',
|
| 15 |
+
headers: { 'Content-Type': 'application/json' },
|
| 16 |
+
body: JSON.stringify({ text }),
|
| 17 |
+
});
|
| 18 |
+
if (!res.ok) throw new Error('TTS failed');
|
| 19 |
+
|
| 20 |
+
const blob = await res.blob();
|
| 21 |
+
const url = URL.createObjectURL(blob);
|
| 22 |
+
const audio = new Audio(url);
|
| 23 |
+
audioRef.current = audio;
|
| 24 |
+
|
| 25 |
+
return new Promise((resolve) => {
|
| 26 |
+
audio.onended = () => {
|
| 27 |
+
setIsSpeaking(false);
|
| 28 |
+
URL.revokeObjectURL(url);
|
| 29 |
+
resolve();
|
| 30 |
+
};
|
| 31 |
+
audio.onerror = () => {
|
| 32 |
+
setIsSpeaking(false);
|
| 33 |
+
URL.revokeObjectURL(url);
|
| 34 |
+
resolve();
|
| 35 |
+
};
|
| 36 |
+
audio.play().catch(() => {
|
| 37 |
+
setIsSpeaking(false);
|
| 38 |
+
resolve();
|
| 39 |
+
});
|
| 40 |
+
});
|
| 41 |
+
} catch (err) {
|
| 42 |
+
console.error('TTS error:', err);
|
| 43 |
+
setIsSpeaking(false);
|
| 44 |
+
}
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
const stop = useCallback(() => {
|
| 48 |
+
if (audioRef.current) {
|
| 49 |
+
audioRef.current.pause();
|
| 50 |
+
audioRef.current.currentTime = 0;
|
| 51 |
+
}
|
| 52 |
+
setIsSpeaking(false);
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
return { speak, stop, isSpeaking };
|
| 56 |
+
}
|
frontend/src/hooks/useWakeWord.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
// Very broad matching — French STT transcribes "Oppy" in many unexpected ways
|
| 4 |
+
function matchesWakeWord(transcript) {
|
| 5 |
+
const t = transcript.toLowerCase().replace(/[.,!?'"\-]/g, ' ').trim();
|
| 6 |
+
return /opp?[iey]|au?pp?[iey]|oh? p[iey]|au pi|o[bp]+ ?[iey]|hop[iey]|op[iey]|hey op|ok op|aux? pi/.test(t);
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function useWakeWord({ enabled, onDetected }) {
|
| 10 |
+
const recognitionRef = useRef(null);
|
| 11 |
+
const isRunningRef = useRef(false);
|
| 12 |
+
const enabledRef = useRef(enabled);
|
| 13 |
+
const [micActive, setMicActive] = useState(false);
|
| 14 |
+
const [micError, setMicError] = useState(false);
|
| 15 |
+
enabledRef.current = enabled;
|
| 16 |
+
|
| 17 |
+
const startListening = useCallback(() => {
|
| 18 |
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 19 |
+
if (!SpeechRecognition || !enabledRef.current || isRunningRef.current) return;
|
| 20 |
+
|
| 21 |
+
const recognition = new SpeechRecognition();
|
| 22 |
+
recognition.lang = 'fr-FR';
|
| 23 |
+
recognition.continuous = true;
|
| 24 |
+
recognition.interimResults = true;
|
| 25 |
+
|
| 26 |
+
recognition.onstart = () => {
|
| 27 |
+
setMicActive(true);
|
| 28 |
+
setMicError(false);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
recognition.onresult = (event) => {
|
| 32 |
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
| 33 |
+
const transcript = event.results[i][0].transcript;
|
| 34 |
+
if (matchesWakeWord(transcript)) {
|
| 35 |
+
recognition.stop();
|
| 36 |
+
isRunningRef.current = false;
|
| 37 |
+
setMicActive(false);
|
| 38 |
+
onDetected();
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
recognition.onend = () => {
|
| 45 |
+
isRunningRef.current = false;
|
| 46 |
+
setMicActive(false);
|
| 47 |
+
// Only restart if still enabled AND this recognition instance is still current
|
| 48 |
+
if (enabledRef.current && recognitionRef.current === recognition) {
|
| 49 |
+
setTimeout(() => {
|
| 50 |
+
if (enabledRef.current) startListening();
|
| 51 |
+
}, 300);
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
recognition.onerror = (event) => {
|
| 56 |
+
isRunningRef.current = false;
|
| 57 |
+
setMicActive(false);
|
| 58 |
+
if (event.error === 'not-allowed') {
|
| 59 |
+
setMicError(true);
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
if (enabledRef.current) {
|
| 63 |
+
setTimeout(() => startListening(), 500);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
recognitionRef.current = recognition;
|
| 68 |
+
isRunningRef.current = true;
|
| 69 |
+
try {
|
| 70 |
+
recognition.start();
|
| 71 |
+
} catch {
|
| 72 |
+
isRunningRef.current = false;
|
| 73 |
+
setMicActive(false);
|
| 74 |
+
}
|
| 75 |
+
}, [onDetected]);
|
| 76 |
+
|
| 77 |
+
// Manual trigger — needed because browsers block auto-start without user gesture
|
| 78 |
+
const requestMic = useCallback(() => {
|
| 79 |
+
if (!isRunningRef.current) {
|
| 80 |
+
setMicError(false);
|
| 81 |
+
startListening();
|
| 82 |
+
}
|
| 83 |
+
}, [startListening]);
|
| 84 |
+
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
if (enabled) {
|
| 87 |
+
// Try auto-start (works if mic was already granted)
|
| 88 |
+
startListening();
|
| 89 |
+
} else {
|
| 90 |
+
if (recognitionRef.current) {
|
| 91 |
+
try { recognitionRef.current.abort(); } catch {}
|
| 92 |
+
try { recognitionRef.current.stop(); } catch {}
|
| 93 |
+
recognitionRef.current = null;
|
| 94 |
+
isRunningRef.current = false;
|
| 95 |
+
}
|
| 96 |
+
setMicActive(false);
|
| 97 |
+
}
|
| 98 |
+
return () => {
|
| 99 |
+
if (recognitionRef.current) {
|
| 100 |
+
try { recognitionRef.current.abort(); } catch {}
|
| 101 |
+
try { recognitionRef.current.stop(); } catch {}
|
| 102 |
+
recognitionRef.current = null;
|
| 103 |
+
isRunningRef.current = false;
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
}, [enabled, startListening]);
|
| 107 |
+
|
| 108 |
+
return { micActive, micError, requestMic };
|
| 109 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Text:wght@400;500&display=swap');
|
| 2 |
+
|
| 3 |
+
* {
|
| 4 |
+
margin: 0;
|
| 5 |
+
padding: 0;
|
| 6 |
+
box-sizing: border-box;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
body {
|
| 10 |
+
background: #ffffff;
|
| 11 |
+
color: #1f1f1f;
|
| 12 |
+
font-family: 'Google Sans', 'Segoe UI', Roboto, sans-serif;
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
:root {
|
| 17 |
+
/* Gemini palette */
|
| 18 |
+
--gemini-blue: #4285f4;
|
| 19 |
+
--gemini-purple: #a142f4;
|
| 20 |
+
--gemini-pink: #f439a0;
|
| 21 |
+
--gemini-cyan: #24c1e0;
|
| 22 |
+
--gemini-red: #ea4335;
|
| 23 |
+
--gemini-yellow: #fbbc04;
|
| 24 |
+
--gemini-green: #34a853;
|
| 25 |
+
|
| 26 |
+
/* UI tokens — light theme */
|
| 27 |
+
--bg: #ffffff;
|
| 28 |
+
--card-bg: #f8f9fa;
|
| 29 |
+
--surface: #f1f3f4;
|
| 30 |
+
--border: #e0e0e0;
|
| 31 |
+
--text: #1f1f1f;
|
| 32 |
+
--text-dim: #5f6368;
|
| 33 |
+
--text-faint: #9aa0a6;
|
| 34 |
+
|
| 35 |
+
/* Legacy aliases */
|
| 36 |
+
--green: var(--gemini-green);
|
| 37 |
+
--red: var(--gemini-red);
|
| 38 |
+
--orange: var(--gemini-yellow);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@keyframes pulse {
|
| 42 |
+
0%, 100% { opacity: 1; }
|
| 43 |
+
50% { opacity: 0.5; }
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
@keyframes slideIn {
|
| 47 |
+
from { transform: translateX(-20px); opacity: 0; }
|
| 48 |
+
to { transform: translateX(0); opacity: 1; }
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
@keyframes fadeIn {
|
| 52 |
+
from { opacity: 0; }
|
| 53 |
+
to { opacity: 1; }
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@keyframes fadeInUp {
|
| 57 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 58 |
+
to { opacity: 1; transform: translateY(0); }
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* Orb animations */
|
| 62 |
+
@keyframes breathe {
|
| 63 |
+
0%, 100% { transform: scale(1); }
|
| 64 |
+
50% { transform: scale(1.06); }
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
@keyframes listenPulse {
|
| 68 |
+
0%, 100% { transform: scale(1); }
|
| 69 |
+
50% { transform: scale(1.08); }
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
@keyframes ripple {
|
| 73 |
+
0% { transform: scale(1); opacity: 0.5; }
|
| 74 |
+
100% { transform: scale(2.2); opacity: 0; }
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@keyframes thinkSpin {
|
| 78 |
+
0% { filter: hue-rotate(0deg); transform: scale(1); }
|
| 79 |
+
50% { filter: hue-rotate(60deg); transform: scale(1.04); }
|
| 80 |
+
100% { filter: hue-rotate(0deg); transform: scale(1); }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
@keyframes orbSpin {
|
| 84 |
+
0% { transform: rotate(0deg); }
|
| 85 |
+
100% { transform: rotate(360deg); }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@keyframes speakPulse {
|
| 89 |
+
0%, 100% { transform: scaleY(1) scaleX(1); }
|
| 90 |
+
30% { transform: scaleY(1.08) scaleX(0.97); }
|
| 91 |
+
70% { transform: scaleY(0.95) scaleX(1.04); }
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
@keyframes geminiGradient {
|
| 95 |
+
0% { background-position: 0% 50%; }
|
| 96 |
+
50% { background-position: 100% 50%; }
|
| 97 |
+
100% { background-position: 0% 50%; }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Avatar animations */
|
| 101 |
+
@keyframes blink {
|
| 102 |
+
0% { transform: scaleY(0); }
|
| 103 |
+
50% { transform: scaleY(1); }
|
| 104 |
+
100% { transform: scaleY(0); }
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@keyframes eyeSpin {
|
| 108 |
+
0% { transform: rotate(0deg); }
|
| 109 |
+
100% { transform: rotate(360deg); }
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
@keyframes mouthBar {
|
| 113 |
+
0% { height: 20%; }
|
| 114 |
+
100% { height: 90%; }
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
::-webkit-scrollbar {
|
| 118 |
+
width: 6px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
::-webkit-scrollbar-track {
|
| 122 |
+
background: #ffffff;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
::-webkit-scrollbar-thumb {
|
| 126 |
+
background: #dadce0;
|
| 127 |
+
border-radius: 3px;
|
| 128 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
);
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
host: '0.0.0.0',
|
| 8 |
+
port: 5173,
|
| 9 |
+
},
|
| 10 |
+
});
|
replit.nix
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{ pkgs }: {
|
| 2 |
+
deps = [
|
| 3 |
+
pkgs.python313
|
| 4 |
+
pkgs.nodejs_20
|
| 5 |
+
pkgs.bashInteractive
|
| 6 |
+
];
|
| 7 |
+
}
|
start.sh
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
# Install Python deps
|
| 5 |
+
cd backend
|
| 6 |
+
pip install -q -r requirements.txt 2>/dev/null
|
| 7 |
+
|
| 8 |
+
# Build frontend
|
| 9 |
+
cd ../frontend
|
| 10 |
+
npm install --silent 2>/dev/null
|
| 11 |
+
npx vite build --outDir ../backend/static 2>/dev/null
|
| 12 |
+
echo "Frontend built."
|
| 13 |
+
|
| 14 |
+
# Start backend (serves API + static frontend)
|
| 15 |
+
cd ../backend
|
| 16 |
+
exec uvicorn main:app --host 0.0.0.0 --port 8000
|