Luli Maruyama commited on
Commit
b040061
·
1 Parent(s): 67afa20

pushed to hugging face

Browse files
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ node_modules/
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,32 @@
1
  ---
2
- title: Gemini3 Opy
3
- emoji: 💻
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- license: apache-2.0
9
- short_description: Operator monitors your Gmail and calendar to prepare today
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: gemini3-opy
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ license: mit
 
9
  ---
10
 
11
+ # gemini3-opy
12
+
13
+ [![Hugging Face Space](https://img.shields.io/badge/🤗%20Space-gemini3--opy-yellow.svg)](https://huggingface.co/spaces/luli331/gemini3-opy)
14
+
15
+ Minimal FastAPI app scaffold for Hugging Face Spaces (Docker).
16
+
17
+ ## Description
18
+
19
+ This Space runs a FastAPI app with:
20
+ - root route: `GET /` → `{"Hello": "World!"}`
21
+ - static files served from `static/`
22
+
23
+ ## Files
24
+
25
+ - `main.py` — FastAPI entrypoint (`app`)
26
+ - `requirements.txt` — Python dependencies
27
+ - `Dockerfile` — container build/runtime
28
+ - `static/` — static assets folder
29
+
30
+ ## License
31
+
32
+ MIT
backend/agent.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 initiative import evaluate_project
12
+ from mock_data import MOCK_EMAILS, MOCK_EVENTS
13
+ from tools import get_events, read_emails, search_web
14
+ from urgency import score_emails, score_urgency
15
+
16
+ load_dotenv()
17
+
18
+ SYSTEM_PROMPT = """Tu es Oppy — l'assistant personnel d'un etudiant en alternance qui jongle entre ses cours, son entreprise et sa startup.
19
+
20
+ 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.
21
+
22
+ 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.
23
+
24
+ Style :
25
+ - Parle en francais naturel, comme a l'oral. Tutoie la personne.
26
+ - Sois direct et concret : pas de "il serait judicieux de", mais "fonce sur ca maintenant".
27
+ - Nomme les personnes par leur prenom (Sophie, pas "sophie.renard@bnpparibas.com").
28
+ - Donne des actions claires : "reponds a Sophie", "bloque 2h ce soir pour le TP".
29
+ - Mets de l'emotion quand c'est urgent : "la ca craint", "t'es dans le rouge".
30
+
31
+ Format OBLIGATOIRE :
32
+ - Ecris en paragraphes fluides, comme si tu parlais a quelqu'un. PAS de listes a puces, PAS de tirets, PAS de bullet points.
33
+ - Utilise des phrases completes et enchaine les idees naturellement.
34
+ - 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.
35
+ - Le ton doit etre celui d'un pote qui te brief en 2 minutes au telephone.
36
+
37
+ Exemple de format attendu :
38
+
39
+ **Alternance BNP — URGENT**
40
+
41
+ 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.
42
+
43
+ Commence par le projet le plus urgent. Termine par : "Par quoi tu veux commencer ?"
44
+
45
+ Interdictions :
46
+ - JAMAIS de tirets, puces, bullet points, listes numerotees. Uniquement des phrases et paragraphes.
47
+ - Jamais de jargon IA ou technique inutile.
48
+ - Jamais de "je vais analyser" ou "voici mon analyse" — tu fais, tu ne commentes pas.
49
+ - Ne repete jamais le contenu brut des emails. Synthetise.
50
+ - Ne dis jamais "N/A" ou "aucune donnee". Si tu n'as pas l'info, n'en parle pas.
51
+ """
52
+
53
+ TOOL_FUNCTIONS = {
54
+ "read_emails": read_emails,
55
+ "get_events": get_events,
56
+ "search_web": search_web,
57
+ }
58
+
59
+ TOOL_DECLARATIONS = [
60
+ types.Tool(function_declarations=[
61
+ types.FunctionDeclaration(
62
+ name="read_emails",
63
+ description="Lit les emails recents pour un projet. Retourne expediteur, sujet, corps, jours depuis derniere reponse.",
64
+ parameters=types.Schema(
65
+ type="OBJECT",
66
+ properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
67
+ required=["project_id"],
68
+ ),
69
+ ),
70
+ types.FunctionDeclaration(
71
+ name="get_events",
72
+ description="Recupere les evenements calendrier a venir pour un projet. Indique titre, heure, et si un bloc de preparation existe.",
73
+ parameters=types.Schema(
74
+ type="OBJECT",
75
+ properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
76
+ required=["project_id"],
77
+ ),
78
+ ),
79
+ types.FunctionDeclaration(
80
+ name="search_web",
81
+ description="Recherche des signaux externes pertinents pour un projet (news, funding, annonces).",
82
+ parameters=types.Schema(
83
+ type="OBJECT",
84
+ properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},
85
+ required=["project_id"],
86
+ ),
87
+ ),
88
+ ])
89
+ ]
90
+
91
+ MAX_ITERATIONS = 15
92
+ TIMEOUT_SECONDS = 30
93
+
94
+
95
+ def build_operator_context(projects: list[dict]) -> str:
96
+ """Build a summary of projects for Gemini context."""
97
+ return json.dumps(
98
+ [{"id": p["id"], "name": p["name"], "contact": p["contact"], "deadline": p["deadline"]}
99
+ for p in projects],
100
+ ensure_ascii=False,
101
+ )
102
+
103
+
104
+ def build_data_context(projects: list[dict]) -> str:
105
+ """Build a full data dump of emails, events and search results for chat context."""
106
+ lines = []
107
+ for p in projects:
108
+ pid = p["id"]
109
+ lines.append(f"\n=== {p['name']} (id: {pid}) ===")
110
+ emails = MOCK_EMAILS.get(pid, [])
111
+ if emails:
112
+ lines.append("Emails:")
113
+ for e in emails:
114
+ silence = f"{e['days_since_reply']}j sans reponse" if e["days_since_reply"] is not None else "newsletter"
115
+ lines.append(f" - De: {e['from']} | Sujet: {e['subject']} | {silence}")
116
+ lines.append(f" {e['body']}")
117
+ events = MOCK_EVENTS.get(pid, [])
118
+ if events:
119
+ lines.append("Calendrier:")
120
+ for ev in events:
121
+ prep = "oui" if ev["prep_block"] else "non"
122
+ lines.append(f" - {ev['title']} a {ev['time']} (bloc prep: {prep})")
123
+ return "\n".join(lines)
124
+
125
+
126
+ def create_chat_session(projects: list[dict]):
127
+ """Create a persistent Gemini chat session with full project context.
128
+
129
+ Returns (client, chat) — caller must keep client alive.
130
+ """
131
+ client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
132
+
133
+ data_context = build_data_context(projects)
134
+ project_summary = build_operator_context(projects)
135
+
136
+ chat_system = (
137
+ "Tu es Oppy, l'assistant personnel vocal d'un etudiant en alternance. "
138
+ "Tu es en mode CONVERSATION. L'utilisateur te parle et te pose des questions. "
139
+ "Tu REPONDS UNIQUEMENT a ce qu'il demande. Tu ne fais JAMAIS de brief complet sauf s'il le demande explicitement.\n\n"
140
+ "REGLE ABSOLUE : lis la question de l'utilisateur et reponds SEULEMENT a cette question. "
141
+ "Si il demande son programme du jour, donne JUSTE les events du jour. "
142
+ "Si il demande un mail precis, resume JUSTE ce mail. "
143
+ "Si il demande de l'aide sur un projet, parle JUSTE de ce projet. "
144
+ "Ne liste JAMAIS tous les projets sauf si on te le demande.\n\n"
145
+ "Style :\n"
146
+ "- Parle en francais naturel, tutoie, sois direct comme un pote.\n"
147
+ "- Reponds en 2-5 phrases max. Court et percutant.\n"
148
+ "- Nomme les gens par leur prenom (Sophie, pas sophie.renard@bnpparibas.com).\n"
149
+ "- Termine par une action concrete ou une question de suivi.\n"
150
+ "- Ne repete JAMAIS une reponse precedente.\n"
151
+ "- Pas de tirets, pas de listes a puces. Des phrases.\n\n"
152
+ f"Projets actifs :\n{project_summary}\n\n"
153
+ f"Donnees completes :\n{data_context}"
154
+ )
155
+
156
+ chat = client.chats.create(
157
+ model="gemini-3-flash-preview",
158
+ config=types.GenerateContentConfig(
159
+ system_instruction=chat_system,
160
+ ),
161
+ )
162
+ return client, chat
163
+
164
+
165
+ async def chat_with_operator(message: str, chat_session, on_event: Callable) -> str:
166
+ """Answer a user question using a persistent chat session.
167
+
168
+ Args:
169
+ message: User's question/request.
170
+ chat_session: Persistent Gemini chat session.
171
+ on_event: Async callback for streaming events.
172
+
173
+ Returns:
174
+ The assistant's text response.
175
+ """
176
+ response = chat_session.send_message(message)
177
+ reply = response.text
178
+
179
+ await on_event({
180
+ "type": "chat_reply",
181
+ "text": reply,
182
+ })
183
+
184
+ return reply
185
+
186
+
187
+ async def run_operator(projects: list[dict], on_event: Callable) -> str:
188
+ """Run the Oppy agent loop.
189
+
190
+ Args:
191
+ projects: list of project dicts from config.json
192
+ on_event: async callback(event_dict) called for each step
193
+
194
+ Returns:
195
+ The final brief text.
196
+ """
197
+ client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
198
+
199
+ project_summary = build_operator_context(projects)
200
+
201
+ chat = client.chats.create(
202
+ model="gemini-3-flash-preview",
203
+ config=types.GenerateContentConfig(
204
+ system_instruction=SYSTEM_PROMPT,
205
+ tools=TOOL_DECLARATIONS,
206
+ ),
207
+ )
208
+
209
+ user_msg = (
210
+ f"Voici tes 3 projets actifs :\n{project_summary}\n\n"
211
+ "Commence par scanner chaque projet (emails, calendrier, web), "
212
+ "puis delivre ton brief proactif."
213
+ )
214
+
215
+ response = chat.send_message(user_msg)
216
+ iterations = 0
217
+ start_time = time.time()
218
+ collected_data = {}
219
+
220
+ while iterations < MAX_ITERATIONS and (time.time() - start_time) < TIMEOUT_SECONDS:
221
+ # Check for function calls
222
+ function_calls = []
223
+ for part in response.candidates[0].content.parts:
224
+ if part.function_call:
225
+ function_calls.append(part.function_call)
226
+
227
+ if not function_calls:
228
+ break
229
+
230
+ # Execute each function call
231
+ function_responses = []
232
+ for fc in function_calls:
233
+ tool_name = fc.name
234
+ tool_args = dict(fc.args) if fc.args else {}
235
+
236
+ await on_event({
237
+ "type": "tool_call",
238
+ "tool": tool_name,
239
+ "project": tool_args.get("project_id", ""),
240
+ "status": "running",
241
+ })
242
+
243
+ tool_fn = TOOL_FUNCTIONS.get(tool_name)
244
+ if tool_fn:
245
+ try:
246
+ result = tool_fn(**tool_args)
247
+ except Exception as e:
248
+ result = f"Error: {str(e)}"
249
+ else:
250
+ result = f"Unknown tool: {tool_name}"
251
+
252
+ pid = tool_args.get("project_id", "")
253
+ if pid not in collected_data:
254
+ collected_data[pid] = {}
255
+ collected_data[pid][tool_name] = result
256
+
257
+ await on_event({
258
+ "type": "tool_result",
259
+ "tool": tool_name,
260
+ "project": pid,
261
+ "result": result[:200],
262
+ "status": "done",
263
+ })
264
+
265
+ function_responses.append(
266
+ types.Part.from_function_response(
267
+ name=tool_name,
268
+ response={"result": result},
269
+ )
270
+ )
271
+
272
+ iterations += 1
273
+
274
+ # Send function results back to Gemini
275
+ response = chat.send_message(function_responses)
276
+
277
+ # --- Deterministic scoring phase (single pass, cached) ---
278
+ # Only process known project IDs (skip unknown ones Gemini may have invented)
279
+ valid_pids = {p["id"] for p in projects}
280
+ evaluations = {}
281
+ for pid, data in collected_data.items():
282
+ if pid not in valid_pids:
283
+ continue
284
+
285
+ emails = MOCK_EMAILS.get(pid, [])
286
+ scored_emails = score_emails([dict(e) for e in emails])
287
+
288
+ for e in scored_emails:
289
+ await on_event({
290
+ "type": "urgency",
291
+ "project": pid,
292
+ "email_subject": e["subject"],
293
+ "score": e["urgency_score"],
294
+ })
295
+
296
+ project = next(p for p in projects if p["id"] == pid)
297
+ events = MOCK_EVENTS.get(pid, [])
298
+ search_score = score_urgency(data.get("search_web", ""))
299
+
300
+ evaluation = evaluate_project(project, scored_emails, events, search_score)
301
+ evaluations[pid] = {"evaluation": evaluation, "project": project}
302
+
303
+ await on_event({
304
+ "type": "initiative",
305
+ "project": pid,
306
+ "status": evaluation["status"],
307
+ "alerts": evaluation["alerts"],
308
+ })
309
+
310
+ # --- Final brief with initiative context (reuse cached evaluations) ---
311
+ initiative_summary = ""
312
+ for pid, cached in evaluations.items():
313
+ evaluation = cached["evaluation"]
314
+ project = cached["project"]
315
+ initiative_summary += (
316
+ f"\n[{project.get('name', pid)}] Status: {evaluation['status']}\n"
317
+ f"Alertes: {'; '.join(evaluation['alerts']) if evaluation['alerts'] else 'aucune'}\n"
318
+ )
319
+
320
+ brief_prompt = (
321
+ f"Voici les evaluations d'urgence de tes 3 projets :\n{initiative_summary}\n\n"
322
+ "Genere maintenant ton brief proactif final. Commence par le plus urgent. "
323
+ "5 bullets max par projet. Sois brutalement concis. Parle en francais."
324
+ )
325
+
326
+ brief_response = chat.send_message(brief_prompt)
327
+ brief_text = brief_response.text
328
+
329
+ await on_event({
330
+ "type": "brief",
331
+ "text": brief_text,
332
+ })
333
+
334
+ return brief_text
backend/config.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mode": "demo",
3
+ "projects": [
4
+ {
5
+ "id": "school",
6
+ "name": "Master IA \u2014 Sorbonne",
7
+ "contact": "prof.martinez@sorbonne.fr",
8
+ "deadline": "2026-03-18T23:59:00",
9
+ "color": "#00FF88",
10
+ "keywords": ["cours", "rendu", "memoire", "soutenance", "TP"]
11
+ },
12
+ {
13
+ "id": "company",
14
+ "name": "Alternance \u2014 BNP Paribas",
15
+ "contact": "sophie.renard@bnpparibas.com",
16
+ "deadline": "2026-03-17T09:00:00",
17
+ "color": "#FF4444",
18
+ "keywords": ["sprint", "daily", "jira", "livrable", "prod"]
19
+ },
20
+ {
21
+ "id": "startup",
22
+ "name": "Side Project \u2014 NoctaAI",
23
+ "contact": "yassine@noctaai.com",
24
+ "deadline": "2026-04-05T00:00:00",
25
+ "color": "#FF8800",
26
+ "keywords": ["NoctaAI", "MVP", "landing", "beta", "funding"]
27
+ }
28
+ ]
29
+ }
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,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse, Response
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 mock_data import MOCK_EMAILS, MOCK_EVENTS, MOCK_SEARCH
18
+ from tts import generate_speech
19
+ from urgency import load_model
20
+
21
+ load_dotenv()
22
+
23
+ # Fail fast if API key is missing
24
+ api_key = os.getenv("GOOGLE_API_KEY")
25
+ if not api_key or api_key == "your_key_here":
26
+ raise RuntimeError("GOOGLE_API_KEY not set. Check backend/.env")
27
+
28
+
29
+ # Persistent chat session (created on first /api/chat call)
30
+ _chat_client = None
31
+ _chat_session = None
32
+
33
+
34
+ def get_chat_session():
35
+ global _chat_client, _chat_session
36
+ if _chat_session is None:
37
+ config = load_config()
38
+ _chat_client, _chat_session = create_chat_session(config["projects"])
39
+ return _chat_session
40
+
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app):
44
+ load_model()
45
+ yield
46
+
47
+
48
+ app = FastAPI(title="Oppy", lifespan=lifespan)
49
+
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_credentials=True,
54
+ allow_methods=["*"],
55
+ allow_headers=["*"],
56
+ )
57
+
58
+
59
+ def load_config():
60
+ from pathlib import Path
61
+ config_path = Path(__file__).parent / "config.json"
62
+ with open(config_path) as f:
63
+ return json.load(f)
64
+
65
+
66
+ @app.get("/api/run")
67
+ async def run():
68
+ config = load_config()
69
+ queue = asyncio.Queue()
70
+
71
+ async def on_event(event: dict):
72
+ await queue.put(event)
73
+
74
+ async def generator():
75
+ task = asyncio.create_task(run_operator(config["projects"], on_event))
76
+
77
+ while True:
78
+ try:
79
+ event = await asyncio.wait_for(queue.get(), timeout=1.0)
80
+ yield {"event": event["type"], "data": json.dumps(event, ensure_ascii=False)}
81
+ if event["type"] == "brief":
82
+ break
83
+ except asyncio.TimeoutError:
84
+ if task.done():
85
+ break
86
+ continue
87
+
88
+ await task
89
+
90
+ return EventSourceResponse(generator())
91
+
92
+
93
+ class TTSRequest(BaseModel):
94
+ text: str
95
+
96
+
97
+ class ChatRequest(BaseModel):
98
+ message: str
99
+
100
+
101
+ @app.post("/api/tts")
102
+ async def tts(req: TTSRequest):
103
+ audio_bytes = generate_speech(req.text)
104
+ return Response(content=audio_bytes, media_type="audio/wav")
105
+
106
+
107
+ @app.post("/api/chat")
108
+ async def chat(req: ChatRequest):
109
+ session = get_chat_session()
110
+ queue = asyncio.Queue()
111
+
112
+ async def on_event(event: dict):
113
+ await queue.put(event)
114
+
115
+ async def generator():
116
+ task = asyncio.create_task(
117
+ chat_with_operator(req.message, session, on_event)
118
+ )
119
+
120
+ while True:
121
+ try:
122
+ event = await asyncio.wait_for(queue.get(), timeout=1.0)
123
+ yield {"event": event["type"], "data": json.dumps(event, ensure_ascii=False)}
124
+ if event["type"] == "chat_reply":
125
+ break
126
+ except asyncio.TimeoutError:
127
+ if task.done():
128
+ break
129
+ continue
130
+
131
+ await task
132
+
133
+ return EventSourceResponse(generator())
134
+
135
+
136
+ @app.get("/api/project/{project_id}/sources")
137
+ async def project_sources(project_id: str):
138
+ emails = MOCK_EMAILS.get(project_id, [])
139
+ events = MOCK_EVENTS.get(project_id, [])
140
+ search = MOCK_SEARCH.get(project_id, "")
141
+ return {
142
+ "emails": emails,
143
+ "events": events,
144
+ "search": search,
145
+ }
146
+
147
+
148
+ @app.get("/api/health")
149
+ async def health():
150
+ return {"status": "ok"}
151
+
152
+
153
+ # --- Serve frontend static files (for Replit / production) ---
154
+ _static_dir = Path(__file__).parent / "static"
155
+ if _static_dir.exists():
156
+ app.mount("/assets", StaticFiles(directory=_static_dir / "assets"), name="assets")
157
+
158
+ @app.get("/{full_path:path}")
159
+ async def serve_spa(full_path: str):
160
+ """Serve the React SPA for any non-API route."""
161
+ file_path = _static_dir / full_path
162
+ if file_path.is_file():
163
+ return FileResponse(file_path)
164
+ 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,6 @@
 
 
 
 
 
 
 
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
backend/test_smoke.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Use gemini-2.0-flash — update MODEL_NAME if hackathon provides different model
16
+ MODEL_NAME = "gemini-2.0-flash"
17
+
18
+ response = client.models.generate_content(
19
+ model=MODEL_NAME,
20
+ contents="Reply with exactly: OK",
21
+ )
22
+ assert "OK" in response.text
23
+ print(f"Gemini OK (model={MODEL_NAME}): {response.text.strip()}")
24
+
25
+
26
+ def test_gemini_function_calling_api():
27
+ """Verify the SDK supports the function calling API we need."""
28
+ from google.genai import types
29
+
30
+ assert hasattr(types.Part, "from_function_response"), (
31
+ "types.Part.from_function_response not found — SDK version may be incompatible. "
32
+ "Try: pip install --upgrade google-genai"
33
+ )
34
+ print("Gemini SDK function calling API: OK")
35
+
36
+
37
+ def test_huggingface_model():
38
+ import torch
39
+ from sentence_transformers import CrossEncoder
40
+
41
+ model = CrossEncoder(
42
+ "cross-encoder/ms-marco-MiniLM-L6-v2",
43
+ default_activation_function=torch.nn.Sigmoid(),
44
+ )
45
+ pairs = [("urgent action required deadline missed", "Le product owner attend les corrections avant lundi. C'est bloquant.")]
46
+ scores = model.predict(pairs)
47
+ score = float(scores[0])
48
+ assert 0.0 <= score <= 1.0
49
+ print(f"HuggingFace OK: score={score:.3f}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ test_gemini_connection()
54
+ test_gemini_function_calling_api()
55
+ test_huggingface_model()
56
+ print("\nAll smoke tests passed.")
backend/tools.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mock_data import MOCK_EMAILS, MOCK_EVENTS, MOCK_SEARCH
2
+
3
+
4
+ def read_emails(project_id: str) -> str:
5
+ """Read recent emails for a project. Returns sender, subject, body, and days since last reply."""
6
+ emails = MOCK_EMAILS.get(project_id, [])
7
+ if not emails:
8
+ return f"No emails found for project {project_id}."
9
+ lines = []
10
+ for e in emails:
11
+ silence = f"{e['days_since_reply']} days ago" if e["days_since_reply"] is not None else "N/A (newsletter)"
12
+ lines.append(
13
+ f"From: {e['from']}\n"
14
+ f"Subject: {e['subject']}\n"
15
+ f"Body: {e['body']}\n"
16
+ f"Last reply: {silence}"
17
+ )
18
+ return "\n---\n".join(lines)
19
+
20
+
21
+ def get_events(project_id: str) -> str:
22
+ """Get upcoming calendar events for a project. Shows title, time, and whether a prep block exists."""
23
+ events = MOCK_EVENTS.get(project_id, [])
24
+ if not events:
25
+ return f"No upcoming events for project {project_id}."
26
+ lines = []
27
+ for ev in events:
28
+ prep = "YES" if ev["prep_block"] else "NO"
29
+ lines.append(f"{ev['title']} at {ev['time']} (prep block: {prep})")
30
+ return "\n".join(lines)
31
+
32
+
33
+ def search_web(project_id: str) -> str:
34
+ """Search for recent external signals relevant to a project (news, funding, announcements)."""
35
+ return MOCK_SEARCH.get(project_id, "No relevant external signals found.")
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,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
10
+ const PROJECTS = [
11
+ { id: 'school', name: 'Master IA \u2014 Sorbonne', contact: 'prof.martinez@sorbonne.fr', color: '#4285f4' },
12
+ { id: 'company', name: 'Alternance \u2014 BNP Paribas', contact: 'sophie.renard@bnpparibas.com', color: '#ea4335' },
13
+ { id: 'startup', name: 'Side Project \u2014 NoctaAI', contact: 'yassine@noctaai.com', color: '#fbbc04' },
14
+ ];
15
+
16
+ export default function App() {
17
+ const { sendChat } = useOperatorSSE();
18
+ const { speak } = useTTS();
19
+
20
+ // Start directly in IDLE — no scan needed
21
+ const [phase, setPhase] = useState('IDLE');
22
+ const [messages, setMessages] = useState([]);
23
+ const phaseRef = useRef(phase);
24
+ phaseRef.current = phase;
25
+
26
+ const [inputText, setInputText] = useState('');
27
+ const [showTranscript, setShowTranscript] = useState(false);
28
+
29
+ const handleWakeWord = useCallback(() => {
30
+ if (phaseRef.current === 'IDLE') setPhase('LISTENING');
31
+ }, []);
32
+
33
+ const { micActive, micError, requestMic } = useWakeWord({
34
+ enabled: phase === 'IDLE',
35
+ onDetected: handleWakeWord,
36
+ });
37
+
38
+ const handleOrbClick = useCallback(() => {
39
+ if (phaseRef.current === 'IDLE') {
40
+ setPhase('LISTENING');
41
+ }
42
+ }, []);
43
+
44
+ const handleUserSaid = useCallback(async (text) => {
45
+ if (!text.trim()) return;
46
+
47
+ const bye = text.toLowerCase();
48
+ if (bye.includes('merci') || bye.includes("c'est bon") || bye.includes('a plus') || bye.includes('au revoir')) {
49
+ setMessages(prev => [...prev, { role: 'user', text }]);
50
+ const farewell = "Ok, je reste la si tu as besoin. Dis 'Oppy' quand tu veux.";
51
+ setMessages(prev => [...prev, { role: 'assistant', text: farewell }]);
52
+ setPhase('SPEAKING');
53
+ await speak(farewell);
54
+ setPhase('IDLE');
55
+ return;
56
+ }
57
+
58
+ setMessages(prev => [...prev, { role: 'user', text }]);
59
+ setPhase('SPEAKING');
60
+ const acks = [
61
+ "D'accord, donne-moi un petit instant.",
62
+ "Je regarde ca tout de suite.",
63
+ "Laisse-moi verifier.",
64
+ "Une seconde, je cherche.",
65
+ ];
66
+ const ack = acks[Math.floor(Math.random() * acks.length)];
67
+ const ackDone = speak(ack);
68
+ const replyPromise = sendChat(text);
69
+ await ackDone;
70
+ setPhase('THINKING');
71
+
72
+ try {
73
+ const reply = await replyPromise;
74
+ if (reply) {
75
+ setMessages(prev => [...prev, { role: 'assistant', text: reply }]);
76
+ setPhase('SPEAKING');
77
+ await speak(reply);
78
+ if (phaseRef.current === 'SPEAKING') setPhase('LISTENING');
79
+ } else {
80
+ setPhase('LISTENING');
81
+ }
82
+ } catch (err) {
83
+ console.error('Chat error:', err);
84
+ setPhase('LISTENING');
85
+ }
86
+ }, [sendChat, speak]);
87
+
88
+ const handleTimeout = useCallback(() => {
89
+ if (phaseRef.current === 'LISTENING') setPhase('IDLE');
90
+ }, []);
91
+
92
+ const { transcript } = useSpeechRecognition({
93
+ enabled: phase === 'LISTENING',
94
+ onResult: handleUserSaid,
95
+ onTimeout: handleTimeout,
96
+ timeoutMs: 20000,
97
+ });
98
+
99
+ const handleSubmit = (e) => {
100
+ e.preventDefault();
101
+ const text = inputText.trim();
102
+ if (!text || phase === 'THINKING' || phase === 'SPEAKING') return;
103
+ setInputText('');
104
+ handleUserSaid(text);
105
+ };
106
+
107
+ let label = '';
108
+ if (phase === 'IDLE') {
109
+ if (micError) label = "Clique sur l'orbe pour autoriser le micro";
110
+ else if (!micActive) label = "Clique sur l'orbe pour activer le micro";
111
+ else label = "Micro actif — dis 'Oppy' pour commencer";
112
+ }
113
+ else if (phase === 'LISTENING') label = transcript || "Je t'ecoute...";
114
+ else if (phase === 'THINKING') label = 'Oppy reflechit...';
115
+ else if (phase === 'SPEAKING') label = '';
116
+
117
+ const micDotColor = phase === 'IDLE' && micActive ? '#34a853' : phase === 'IDLE' && micError ? '#ea4335' : null;
118
+
119
+ return (
120
+ <div style={{
121
+ minHeight: '100vh',
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ padding: '40px 20px 100px',
127
+ position: 'relative',
128
+ }}>
129
+ {/* Logo */}
130
+ <div style={{
131
+ position: 'fixed',
132
+ top: 0,
133
+ left: 0,
134
+ padding: '20px 24px',
135
+ zIndex: 10,
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ gap: '10px',
139
+ }}>
140
+ <span style={{
141
+ fontSize: '20px',
142
+ background: 'linear-gradient(135deg, #4285f4, #a142f4, #f439a0)',
143
+ WebkitBackgroundClip: 'text',
144
+ WebkitTextFillColor: 'transparent',
145
+ }}>
146
+ &#10022;
147
+ </span>
148
+ <h1 style={{
149
+ fontSize: '16px',
150
+ fontWeight: 500,
151
+ color: '#1f1f1f',
152
+ margin: 0,
153
+ letterSpacing: '0.5px',
154
+ }}>
155
+ Oppy
156
+ </h1>
157
+ </div>
158
+
159
+ {/* Orb */}
160
+ <JarvisOrb phase={phase} onClick={handleOrbClick} isLoading={false} />
161
+
162
+ {/* Mic status dot */}
163
+ {micDotColor && (
164
+ <div style={{
165
+ marginTop: '12px',
166
+ display: 'flex',
167
+ alignItems: 'center',
168
+ gap: '6px',
169
+ }}>
170
+ <div style={{
171
+ width: '8px', height: '8px', borderRadius: '50%',
172
+ background: micDotColor,
173
+ animation: micActive ? 'pulse 2s infinite' : 'none',
174
+ }} />
175
+ <span style={{ fontSize: '11px', color: micDotColor, letterSpacing: '0.5px' }}>
176
+ {micActive ? 'Micro actif' : 'Micro bloque'}
177
+ </span>
178
+ </div>
179
+ )}
180
+
181
+ {/* Phase label */}
182
+ <div style={{
183
+ marginTop: micDotColor ? '8px' : '24px',
184
+ fontSize: '14px',
185
+ color: phase === 'LISTENING' && transcript ? '#4285f4' : '#5f6368',
186
+ textAlign: 'center',
187
+ minHeight: '20px',
188
+ animation: phase === 'THINKING' ? 'pulse 1.5s infinite' : 'none',
189
+ fontWeight: phase === 'LISTENING' && transcript ? 500 : 400,
190
+ }}>
191
+ {label}
192
+ </div>
193
+
194
+ {/* Transcript toggle */}
195
+ {messages.length > 0 && (
196
+ <div style={{ marginTop: '16px', textAlign: 'center' }}>
197
+ <button
198
+ onClick={() => setShowTranscript(!showTranscript)}
199
+ style={{
200
+ background: showTranscript ? '#f1f3f4' : 'transparent',
201
+ border: '1px solid #e0e0e0',
202
+ color: '#5f6368',
203
+ padding: '6px 16px',
204
+ borderRadius: '16px',
205
+ fontFamily: 'inherit',
206
+ fontSize: '12px',
207
+ cursor: 'pointer',
208
+ transition: 'all 0.2s',
209
+ }}
210
+ >
211
+ {showTranscript ? 'Masquer la transcription' : 'Afficher la transcription'}
212
+ </button>
213
+ </div>
214
+ )}
215
+ {showTranscript && (
216
+ <div style={{ marginTop: '12px', width: '100%', maxWidth: '600px' }}>
217
+ <ConversationOverlay messages={messages} isThinking={phase === 'THINKING'} />
218
+ </div>
219
+ )}
220
+
221
+ {/* Text input */}
222
+ <form
223
+ onSubmit={handleSubmit}
224
+ style={{
225
+ position: 'fixed',
226
+ bottom: '50px',
227
+ left: '50%',
228
+ transform: 'translateX(-50%)',
229
+ width: '100%',
230
+ maxWidth: '500px',
231
+ display: 'flex',
232
+ gap: '8px',
233
+ padding: '0 20px',
234
+ zIndex: 15,
235
+ }}
236
+ >
237
+ <input
238
+ type="text"
239
+ value={inputText}
240
+ onChange={(e) => setInputText(e.target.value)}
241
+ placeholder="Ecris ta question ici..."
242
+ style={{
243
+ flex: 1,
244
+ background: '#f8f9fa',
245
+ border: '1px solid #e0e0e0',
246
+ borderRadius: '24px',
247
+ padding: '12px 20px',
248
+ color: '#1f1f1f',
249
+ fontFamily: 'inherit',
250
+ fontSize: '14px',
251
+ outline: 'none',
252
+ }}
253
+ />
254
+ <button
255
+ type="submit"
256
+ disabled={!inputText.trim() || phase === 'THINKING'}
257
+ style={{
258
+ background: inputText.trim() ? '#4285f4' : '#f1f3f4',
259
+ color: inputText.trim() ? '#fff' : '#9aa0a6',
260
+ border: 'none',
261
+ borderRadius: '24px',
262
+ padding: '12px 20px',
263
+ fontFamily: 'inherit',
264
+ fontSize: '13px',
265
+ fontWeight: 500,
266
+ cursor: !inputText.trim() ? 'not-allowed' : 'pointer',
267
+ transition: 'all 0.2s',
268
+ }}
269
+ >
270
+ Envoyer
271
+ </button>
272
+ </form>
273
+
274
+ {/* Project cards drawer */}
275
+ <ProjectCardsDrawer projects={PROJECTS} statuses={{}} />
276
+ </div>
277
+ );
278
+ }
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,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function Header({ scanProgress, scanPhase }) {
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
+ {scanProgress > 0 && scanProgress < 100 && (
31
+ <span style={{
32
+ fontSize: '12px',
33
+ color: 'var(--green)',
34
+ letterSpacing: '2px',
35
+ fontWeight: 700,
36
+ animation: 'pulse 1.5s infinite',
37
+ }}>
38
+ SCANNING...
39
+ </span>
40
+ )}
41
+ </div>
42
+
43
+ {/* Progress bar */}
44
+ {scanProgress > 0 && scanProgress < 100 && (
45
+ <div style={{ marginTop: '16px' }}>
46
+ <div style={{
47
+ display: 'flex',
48
+ justifyContent: 'space-between',
49
+ alignItems: 'center',
50
+ marginBottom: '6px',
51
+ }}>
52
+ <span style={{
53
+ fontSize: '11px',
54
+ color: 'var(--text-dim)',
55
+ letterSpacing: '1px',
56
+ }}>
57
+ {scanPhase}
58
+ </span>
59
+ <span style={{
60
+ fontSize: '11px',
61
+ color: 'var(--text-dim)',
62
+ fontVariantNumeric: 'tabular-nums',
63
+ }}>
64
+ {scanProgress}%
65
+ </span>
66
+ </div>
67
+ <div style={{
68
+ width: '100%',
69
+ height: '6px',
70
+ background: '#1a1a1a',
71
+ borderRadius: '3px',
72
+ overflow: 'hidden',
73
+ }}>
74
+ <div style={{
75
+ width: `${scanProgress}%`,
76
+ height: '100%',
77
+ background: 'linear-gradient(90deg, var(--green), #00cc6a)',
78
+ borderRadius: '3px',
79
+ boxShadow: '0 0 8px rgba(0, 255, 136, 0.4)',
80
+ }} />
81
+ </div>
82
+ </div>
83
+ )}
84
+ </header>
85
+ );
86
+ }
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/useOperatorSSE.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { 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 sendChat = useCallback(async (message) => {
7
+ const res = await fetch(`${BACKEND_URL}/api/chat`, {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json' },
10
+ body: JSON.stringify({ message }),
11
+ });
12
+
13
+ const reader = res.body.getReader();
14
+ const decoder = new TextDecoder();
15
+ let replyText = '';
16
+ let buffer = '';
17
+
18
+ while (true) {
19
+ const { done, value } = await reader.read();
20
+ if (done) break;
21
+ buffer += decoder.decode(value, { stream: true });
22
+
23
+ const lines = buffer.split('\n');
24
+ buffer = lines.pop() || '';
25
+
26
+ for (const line of lines) {
27
+ if (line.startsWith('data:') || line.startsWith('data: ')) {
28
+ try {
29
+ const data = JSON.parse(line.replace(/^data:\s*/, ''));
30
+ if (data.type === 'chat_reply') {
31
+ replyText = data.text;
32
+ }
33
+ } catch { /* skip */ }
34
+ }
35
+ }
36
+ }
37
+
38
+ return replyText;
39
+ }, []);
40
+
41
+ return { sendChat };
42
+ }
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 (wake word uses it)
107
+ const timer = setTimeout(() => start(), 600);
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
+ });
main.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+
4
+ app = FastAPI()
5
+
6
+ app.mount("/static", StaticFiles(directory="static"), name="static")
7
+
8
+
9
+ @app.get("/")
10
+ def greet_json():
11
+ return {"Hello": "World!"}
replit.nix ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ { pkgs }: {
2
+ deps = [
3
+ pkgs.python313
4
+ pkgs.nodejs_20
5
+ pkgs.bashInteractive
6
+ ];
7
+ }
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
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
static/index.html ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>gemini3-opy</title>
7
+ </head>
8
+ <body>
9
+ <h1>gemini3-opy static assets ready ✅</h1>
10
+ </body>
11
+ </html>