whung99 Claude Opus 4.6 commited on
Commit
0d37119
·
1 Parent(s): c859192

feat: deploy Oppy with Google API integration

Browse files

Full-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 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
- title: Gemini3hackathon Oppy
3
- emoji: 😻
4
- colorFrom: indigo
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ &#9632; 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
+ &#10022;
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
+ &#9632; 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} &middot; {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' }}>&#9654;</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