Spaces:
Runtime error
Runtime error
Luli Maruyama commited on
Commit ·
b040061
1
Parent(s): 67afa20
pushed to hugging face
Browse files- .gitignore +5 -0
- Dockerfile +13 -0
- README.md +27 -7
- backend/agent.py +334 -0
- backend/config.json +29 -0
- backend/initiative.py +50 -0
- backend/main.py +164 -0
- backend/mock_data.py +120 -0
- backend/requirements.txt +6 -0
- backend/test_smoke.py +56 -0
- backend/tools.py +35 -0
- backend/tts.py +63 -0
- backend/urgency.py +25 -0
- docs/superpowers/plans/2026-03-14-operator-plan.md +1522 -0
- docs/superpowers/specs/2026-03-14-operator-design.md +255 -0
- frontend/index.html +12 -0
- frontend/package-lock.json +1677 -0
- frontend/package.json +19 -0
- frontend/public/avatar.png +0 -0
- frontend/src/App.jsx +278 -0
- frontend/src/components/BriefPanel.jsx +147 -0
- frontend/src/components/ConversationOverlay.jsx +65 -0
- frontend/src/components/Header.jsx +86 -0
- frontend/src/components/JarvisOrb.jsx +110 -0
- frontend/src/components/OppyFace.jsx +162 -0
- frontend/src/components/ProjectCard.jsx +229 -0
- frontend/src/components/ProjectCardsDrawer.jsx +66 -0
- frontend/src/components/ToolFeed.jsx +86 -0
- frontend/src/components/VoiceControl.jsx +350 -0
- frontend/src/hooks/useOperatorSSE.js +42 -0
- frontend/src/hooks/useSpeechRecognition.js +121 -0
- frontend/src/hooks/useTTS.js +56 -0
- frontend/src/hooks/useWakeWord.js +109 -0
- frontend/src/index.css +128 -0
- frontend/src/main.jsx +10 -0
- frontend/vite.config.js +10 -0
- main.py +11 -0
- replit.nix +7 -0
- requirements.txt +2 -0
- start.sh +16 -0
- static/index.html +11 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
license:
|
| 9 |
-
short_description: Operator monitors your Gmail and calendar to prepare today
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](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 |
+
■ 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 |
+
✦
|
| 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 |
+
■ 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} · {formatDate(email.date)}
|
| 137 |
+
</div>
|
| 138 |
+
<div style={{ color: '#3c4043', fontSize: '12px', lineHeight: '1.5', marginBottom: '8px' }}>
|
| 139 |
+
{email.body}
|
| 140 |
+
</div>
|
| 141 |
+
<div style={{ display: 'flex', gap: '8px' }}>
|
| 142 |
+
<a
|
| 143 |
+
href={`mailto:${email.from}?subject=Re: ${email.subject}`}
|
| 144 |
+
style={{
|
| 145 |
+
fontSize: '11px', color: '#4285f4', textDecoration: 'none',
|
| 146 |
+
padding: '4px 10px', border: '1px solid #4285f4',
|
| 147 |
+
borderRadius: '14px', fontWeight: 500,
|
| 148 |
+
}}
|
| 149 |
+
>
|
| 150 |
+
Repondre
|
| 151 |
+
</a>
|
| 152 |
+
<a
|
| 153 |
+
href={`https://mail.google.com/mail/u/0/#search/from:${email.from}+subject:${encodeURIComponent(email.subject)}`}
|
| 154 |
+
target="_blank" rel="noopener noreferrer"
|
| 155 |
+
style={{
|
| 156 |
+
fontSize: '11px', color: '#5f6368', textDecoration: 'none',
|
| 157 |
+
padding: '4px 10px', border: '1px solid #e0e0e0',
|
| 158 |
+
borderRadius: '14px',
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
Ouvrir dans Gmail
|
| 162 |
+
</a>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
))}
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
|
| 169 |
+
{/* Events */}
|
| 170 |
+
{sources.events && sources.events.length > 0 && (
|
| 171 |
+
<div style={{ marginBottom: '16px' }}>
|
| 172 |
+
<div style={{ fontSize: '11px', color: project.color, letterSpacing: '1px', marginBottom: '8px', fontWeight: 600 }}>
|
| 173 |
+
CALENDRIER
|
| 174 |
+
</div>
|
| 175 |
+
{sources.events.map((event, i) => (
|
| 176 |
+
<div key={i} style={{
|
| 177 |
+
padding: '10px 12px', background: '#f8f9fa', borderRadius: '8px',
|
| 178 |
+
marginBottom: '6px', fontSize: '13px',
|
| 179 |
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
| 180 |
+
}}>
|
| 181 |
+
<div>
|
| 182 |
+
<div style={{ color: '#1f1f1f', fontWeight: 600 }}>{event.title}</div>
|
| 183 |
+
<div style={{ color: '#5f6368', fontSize: '12px', marginTop: '2px' }}>
|
| 184 |
+
{formatTime(event.time)}
|
| 185 |
+
{event.prep_block
|
| 186 |
+
? <span style={{ color: '#1e8e3e', marginLeft: '8px', fontWeight: 500 }}>Prep OK</span>
|
| 187 |
+
: <span style={{ color: '#d93025', marginLeft: '8px', fontWeight: 500 }}>Pas de prep</span>
|
| 188 |
+
}
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
<a
|
| 192 |
+
href={`https://calendar.google.com/calendar/r/search?q=${encodeURIComponent(event.title)}`}
|
| 193 |
+
target="_blank" rel="noopener noreferrer"
|
| 194 |
+
style={{
|
| 195 |
+
fontSize: '11px', color: '#4285f4', textDecoration: 'none',
|
| 196 |
+
padding: '4px 10px', border: '1px solid #4285f4',
|
| 197 |
+
borderRadius: '14px', fontWeight: 500, flexShrink: 0,
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
Ouvrir l'agenda
|
| 201 |
+
</a>
|
| 202 |
+
</div>
|
| 203 |
+
))}
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{/* Web signal */}
|
| 208 |
+
{sources.search && (
|
| 209 |
+
<div>
|
| 210 |
+
<div style={{ fontSize: '11px', color: project.color, letterSpacing: '1px', marginBottom: '8px', fontWeight: 600 }}>
|
| 211 |
+
SIGNAL EXTERNE
|
| 212 |
+
</div>
|
| 213 |
+
<div style={{
|
| 214 |
+
padding: '10px 12px', background: '#f8f9fa', borderRadius: '8px',
|
| 215 |
+
fontSize: '12px', color: '#3c4043', lineHeight: '1.5',
|
| 216 |
+
}}>
|
| 217 |
+
{sources.search}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
<div style={{ fontSize: '11px', color: '#9aa0a6', marginTop: '12px', textAlign: 'center' }}>
|
| 223 |
+
Cliquer pour fermer
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
</div>
|
| 228 |
+
);
|
| 229 |
+
}
|
frontend/src/components/ProjectCardsDrawer.jsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import ProjectCard from './ProjectCard';
|
| 3 |
+
|
| 4 |
+
export default function ProjectCardsDrawer({ projects, statuses }) {
|
| 5 |
+
const [open, setOpen] = useState(false);
|
| 6 |
+
|
| 7 |
+
return (
|
| 8 |
+
<div style={{
|
| 9 |
+
position: 'fixed',
|
| 10 |
+
bottom: 0,
|
| 11 |
+
left: 0,
|
| 12 |
+
right: 0,
|
| 13 |
+
zIndex: 20,
|
| 14 |
+
}}>
|
| 15 |
+
{/* Handle bar */}
|
| 16 |
+
<div
|
| 17 |
+
onClick={() => setOpen(!open)}
|
| 18 |
+
style={{
|
| 19 |
+
display: 'flex',
|
| 20 |
+
alignItems: 'center',
|
| 21 |
+
justifyContent: 'center',
|
| 22 |
+
gap: '10px',
|
| 23 |
+
padding: '10px',
|
| 24 |
+
background: '#ffffff',
|
| 25 |
+
borderTop: '1px solid #e0e0e0',
|
| 26 |
+
cursor: 'pointer',
|
| 27 |
+
}}
|
| 28 |
+
>
|
| 29 |
+
<span style={{
|
| 30 |
+
fontSize: '13px',
|
| 31 |
+
color: '#1f1f1f',
|
| 32 |
+
fontWeight: 500,
|
| 33 |
+
}}>
|
| 34 |
+
{open ? 'Fermer les projets' : `${projects.length} projets`}
|
| 35 |
+
</span>
|
| 36 |
+
<span style={{ fontSize: '10px', color: '#5f6368' }}>
|
| 37 |
+
{open ? '\u25BC' : '\u25B2'}
|
| 38 |
+
</span>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
{/* Drawer content */}
|
| 42 |
+
{open && (
|
| 43 |
+
<div style={{
|
| 44 |
+
background: '#f8f9fa',
|
| 45 |
+
borderTop: '1px solid #e0e0e0',
|
| 46 |
+
padding: '16px 24px',
|
| 47 |
+
display: 'flex',
|
| 48 |
+
flexWrap: 'wrap',
|
| 49 |
+
gap: '12px',
|
| 50 |
+
maxHeight: '55vh',
|
| 51 |
+
overflowY: 'auto',
|
| 52 |
+
animation: 'fadeIn 0.2s ease-out',
|
| 53 |
+
}}>
|
| 54 |
+
{projects.map(project => (
|
| 55 |
+
<ProjectCard
|
| 56 |
+
key={project.id}
|
| 57 |
+
project={project}
|
| 58 |
+
status={statuses[project.id]?.status || 'STANDBY'}
|
| 59 |
+
alerts={statuses[project.id]?.alerts || []}
|
| 60 |
+
/>
|
| 61 |
+
))}
|
| 62 |
+
</div>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
frontend/src/components/ToolFeed.jsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const STATUS_ICONS = {
|
| 2 |
+
running: '\u25CF',
|
| 3 |
+
done: '\u2713',
|
| 4 |
+
waiting: '\u25CB',
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
const PROJECT_COLORS = {
|
| 8 |
+
school: '#00FF88',
|
| 9 |
+
company: '#FF4444',
|
| 10 |
+
startup: '#FF8800',
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export default function ToolFeed({ toolCalls }) {
|
| 14 |
+
if (!toolCalls || toolCalls.length === 0) {
|
| 15 |
+
return (
|
| 16 |
+
<div style={{
|
| 17 |
+
background: 'var(--card-bg)',
|
| 18 |
+
padding: '20px',
|
| 19 |
+
borderTop: '1px solid var(--border)',
|
| 20 |
+
}}>
|
| 21 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '8px' }}>
|
| 22 |
+
TOOL FEED
|
| 23 |
+
</h4>
|
| 24 |
+
<p style={{ fontSize: '12px', color: 'var(--text-dim)' }}>
|
| 25 |
+
En attente du scan...
|
| 26 |
+
</p>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div style={{
|
| 33 |
+
background: 'var(--card-bg)',
|
| 34 |
+
padding: '20px',
|
| 35 |
+
borderTop: '1px solid var(--border)',
|
| 36 |
+
maxHeight: '220px',
|
| 37 |
+
overflowY: 'auto',
|
| 38 |
+
}}>
|
| 39 |
+
<h4 style={{ fontSize: '11px', color: 'var(--text-dim)', letterSpacing: '2px', marginBottom: '12px' }}>
|
| 40 |
+
TOOL FEED
|
| 41 |
+
</h4>
|
| 42 |
+
{toolCalls.map((tc, i) => (
|
| 43 |
+
<div
|
| 44 |
+
key={i}
|
| 45 |
+
style={{
|
| 46 |
+
display: 'flex',
|
| 47 |
+
alignItems: 'center',
|
| 48 |
+
gap: '12px',
|
| 49 |
+
padding: '6px 0',
|
| 50 |
+
fontSize: '13px',
|
| 51 |
+
animation: 'slideIn 0.3s ease-out',
|
| 52 |
+
animationDelay: `${i * 0.1}s`,
|
| 53 |
+
animationFillMode: 'both',
|
| 54 |
+
}}
|
| 55 |
+
>
|
| 56 |
+
<span style={{
|
| 57 |
+
color: tc.status === 'done' ? '#00FF88' : tc.status === 'running' ? '#FF8800' : '#666',
|
| 58 |
+
minWidth: '14px',
|
| 59 |
+
}}>
|
| 60 |
+
{STATUS_ICONS[tc.status] || STATUS_ICONS.waiting}
|
| 61 |
+
</span>
|
| 62 |
+
<span style={{ color: '#fff', minWidth: '180px', fontSize: '12px' }}>
|
| 63 |
+
{tc.tool}(<span style={{ color: PROJECT_COLORS[tc.project] || '#888' }}>{tc.project}</span>)
|
| 64 |
+
</span>
|
| 65 |
+
<div style={{
|
| 66 |
+
flex: 1,
|
| 67 |
+
height: '4px',
|
| 68 |
+
background: '#222',
|
| 69 |
+
borderRadius: '2px',
|
| 70 |
+
overflow: 'hidden',
|
| 71 |
+
}}>
|
| 72 |
+
<div style={{
|
| 73 |
+
width: tc.status === 'done' ? '100%' : tc.status === 'running' ? '60%' : '0%',
|
| 74 |
+
height: '100%',
|
| 75 |
+
background: PROJECT_COLORS[tc.project] || 'var(--green)',
|
| 76 |
+
transition: 'width 0.5s ease',
|
| 77 |
+
}} />
|
| 78 |
+
</div>
|
| 79 |
+
<span style={{ fontSize: '11px', color: 'var(--text-dim)', minWidth: '40px', textAlign: 'right' }}>
|
| 80 |
+
{tc.status === 'done' ? 'done' : tc.status === 'running' ? '...' : 'wait'}
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
))}
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
frontend/src/components/VoiceControl.jsx
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || (import.meta.env.DEV ? 'http://localhost:8000' : '');
|
| 4 |
+
|
| 5 |
+
export default function VoiceControl({ onReply, chatMessages, visible }) {
|
| 6 |
+
const [isListening, setIsListening] = useState(false);
|
| 7 |
+
const [transcript, setTranscript] = useState('');
|
| 8 |
+
const [inputText, setInputText] = useState('');
|
| 9 |
+
const [isThinking, setIsThinking] = useState(false);
|
| 10 |
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
| 11 |
+
const recognitionRef = useRef(null);
|
| 12 |
+
const audioRef = useRef(null);
|
| 13 |
+
const chatEndRef = useRef(null);
|
| 14 |
+
const inputRef = useRef(null);
|
| 15 |
+
const hasSpeechAPI = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
| 16 |
+
|
| 17 |
+
// Scroll to bottom when new messages arrive
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 20 |
+
}, [chatMessages, isThinking]);
|
| 21 |
+
|
| 22 |
+
// Focus input when panel becomes visible
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (visible) {
|
| 25 |
+
setTimeout(() => inputRef.current?.focus(), 300);
|
| 26 |
+
}
|
| 27 |
+
}, [visible]);
|
| 28 |
+
|
| 29 |
+
// Setup speech recognition
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 32 |
+
if (!SpeechRecognition) return;
|
| 33 |
+
|
| 34 |
+
const recognition = new SpeechRecognition();
|
| 35 |
+
recognition.lang = 'fr-FR';
|
| 36 |
+
recognition.continuous = false;
|
| 37 |
+
recognition.interimResults = true;
|
| 38 |
+
|
| 39 |
+
recognition.onresult = (event) => {
|
| 40 |
+
let interim = '';
|
| 41 |
+
let final = '';
|
| 42 |
+
for (let i = 0; i < event.results.length; i++) {
|
| 43 |
+
if (event.results[i].isFinal) {
|
| 44 |
+
final += event.results[i][0].transcript;
|
| 45 |
+
} else {
|
| 46 |
+
interim += event.results[i][0].transcript;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
const text = final || interim;
|
| 50 |
+
setTranscript(text);
|
| 51 |
+
setInputText(text);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
recognition.onend = () => {
|
| 55 |
+
setIsListening(false);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
recognition.onerror = (event) => {
|
| 59 |
+
console.error('STT error:', event.error);
|
| 60 |
+
setIsListening(false);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
recognitionRef.current = recognition;
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
// Auto-send when recognition ends with a final transcript
|
| 67 |
+
const pendingSendRef = useRef(false);
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (!isListening && pendingSendRef.current && transcript.trim() && !isThinking) {
|
| 70 |
+
pendingSendRef.current = false;
|
| 71 |
+
sendMessage(transcript.trim());
|
| 72 |
+
}
|
| 73 |
+
}, [isListening]);
|
| 74 |
+
|
| 75 |
+
const toggleMic = () => {
|
| 76 |
+
if (isListening) {
|
| 77 |
+
pendingSendRef.current = true;
|
| 78 |
+
recognitionRef.current?.stop();
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
setTranscript('');
|
| 82 |
+
setInputText('');
|
| 83 |
+
pendingSendRef.current = true;
|
| 84 |
+
recognitionRef.current?.start();
|
| 85 |
+
setIsListening(true);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleSubmit = (e) => {
|
| 89 |
+
e.preventDefault();
|
| 90 |
+
const text = inputText.trim();
|
| 91 |
+
if (!text || isThinking) return;
|
| 92 |
+
sendMessage(text);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const sendMessage = async (text) => {
|
| 96 |
+
setIsThinking(true);
|
| 97 |
+
setInputText('');
|
| 98 |
+
setTranscript('');
|
| 99 |
+
onReply?.({ role: 'user', text });
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
const res = await fetch(`${BACKEND_URL}/api/chat`, {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
headers: { 'Content-Type': 'application/json' },
|
| 105 |
+
body: JSON.stringify({ message: text }),
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
// Read SSE stream for chat reply
|
| 109 |
+
const reader = res.body.getReader();
|
| 110 |
+
const decoder = new TextDecoder();
|
| 111 |
+
let replyText = '';
|
| 112 |
+
let buffer = '';
|
| 113 |
+
|
| 114 |
+
while (true) {
|
| 115 |
+
const { done, value } = await reader.read();
|
| 116 |
+
if (done) break;
|
| 117 |
+
buffer += decoder.decode(value, { stream: true });
|
| 118 |
+
|
| 119 |
+
const lines = buffer.split('\n');
|
| 120 |
+
buffer = lines.pop() || '';
|
| 121 |
+
|
| 122 |
+
for (const line of lines) {
|
| 123 |
+
if (line.startsWith('data:') || line.startsWith('data: ')) {
|
| 124 |
+
try {
|
| 125 |
+
const jsonStr = line.replace(/^data:\s*/, '');
|
| 126 |
+
const data = JSON.parse(jsonStr);
|
| 127 |
+
if (data.type === 'chat_reply') {
|
| 128 |
+
replyText = data.text;
|
| 129 |
+
}
|
| 130 |
+
} catch { /* skip non-JSON lines */ }
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (replyText) {
|
| 136 |
+
onReply?.({ role: 'assistant', text: replyText });
|
| 137 |
+
await speakReply(replyText);
|
| 138 |
+
}
|
| 139 |
+
} catch (err) {
|
| 140 |
+
console.error('Chat error:', err);
|
| 141 |
+
onReply?.({ role: 'assistant', text: 'Erreur de connexion. Reessaie.' });
|
| 142 |
+
} finally {
|
| 143 |
+
setIsThinking(false);
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const speakReply = async (text) => {
|
| 148 |
+
try {
|
| 149 |
+
setIsSpeaking(true);
|
| 150 |
+
const res = await fetch(`${BACKEND_URL}/api/tts`, {
|
| 151 |
+
method: 'POST',
|
| 152 |
+
headers: { 'Content-Type': 'application/json' },
|
| 153 |
+
body: JSON.stringify({ text }),
|
| 154 |
+
});
|
| 155 |
+
if (!res.ok) { setIsSpeaking(false); return; }
|
| 156 |
+
|
| 157 |
+
const blob = await res.blob();
|
| 158 |
+
const url = URL.createObjectURL(blob);
|
| 159 |
+
const audio = new Audio(url);
|
| 160 |
+
audioRef.current = audio;
|
| 161 |
+
|
| 162 |
+
audio.onended = () => {
|
| 163 |
+
setIsSpeaking(false);
|
| 164 |
+
URL.revokeObjectURL(url);
|
| 165 |
+
};
|
| 166 |
+
await audio.play();
|
| 167 |
+
} catch (err) {
|
| 168 |
+
console.error('TTS error:', err);
|
| 169 |
+
setIsSpeaking(false);
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
if (!visible) return null;
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<div style={{
|
| 177 |
+
background: 'var(--card-bg)',
|
| 178 |
+
borderTop: '2px solid var(--green)',
|
| 179 |
+
marginTop: '8px',
|
| 180 |
+
animation: 'fadeIn 0.4s ease-out',
|
| 181 |
+
}}>
|
| 182 |
+
{/* Header */}
|
| 183 |
+
<div style={{
|
| 184 |
+
padding: '16px 24px 0',
|
| 185 |
+
display: 'flex',
|
| 186 |
+
alignItems: 'center',
|
| 187 |
+
gap: '8px',
|
| 188 |
+
}}>
|
| 189 |
+
<span style={{ color: 'var(--green)', fontSize: '14px' }}>▶</span>
|
| 190 |
+
<h4 style={{
|
| 191 |
+
fontSize: '11px',
|
| 192 |
+
color: 'var(--green)',
|
| 193 |
+
letterSpacing: '2px',
|
| 194 |
+
margin: 0,
|
| 195 |
+
}}>
|
| 196 |
+
CONVERSATION AVEC OPERATOR
|
| 197 |
+
</h4>
|
| 198 |
+
{isSpeaking && (
|
| 199 |
+
<span style={{
|
| 200 |
+
fontSize: '10px',
|
| 201 |
+
color: '#6666ff',
|
| 202 |
+
letterSpacing: '1px',
|
| 203 |
+
marginLeft: 'auto',
|
| 204 |
+
animation: 'pulse 1s infinite',
|
| 205 |
+
}}>
|
| 206 |
+
PARLE...
|
| 207 |
+
</span>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Chat messages */}
|
| 212 |
+
<div style={{
|
| 213 |
+
maxHeight: '300px',
|
| 214 |
+
overflowY: 'auto',
|
| 215 |
+
padding: '12px 24px',
|
| 216 |
+
display: 'flex',
|
| 217 |
+
flexDirection: 'column',
|
| 218 |
+
gap: '10px',
|
| 219 |
+
}}>
|
| 220 |
+
{(!chatMessages || chatMessages.length === 0) && !isThinking && (
|
| 221 |
+
<p style={{
|
| 222 |
+
fontSize: '13px',
|
| 223 |
+
color: 'var(--text-dim)',
|
| 224 |
+
fontStyle: 'italic',
|
| 225 |
+
margin: '8px 0',
|
| 226 |
+
}}>
|
| 227 |
+
Dis-moi par quoi tu veux commencer, ou pose-moi une question sur tes projets.
|
| 228 |
+
</p>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{chatMessages && chatMessages.map((msg, i) => (
|
| 232 |
+
<div key={i} style={{
|
| 233 |
+
padding: '10px 14px',
|
| 234 |
+
borderRadius: '4px',
|
| 235 |
+
fontSize: '13px',
|
| 236 |
+
lineHeight: '1.6',
|
| 237 |
+
whiteSpace: 'pre-wrap',
|
| 238 |
+
background: msg.role === 'user' ? '#1a2a1a' : '#1a1a2a',
|
| 239 |
+
borderLeft: `3px solid ${msg.role === 'user' ? 'var(--green)' : '#6666ff'}`,
|
| 240 |
+
color: '#e0e0e0',
|
| 241 |
+
animation: 'slideIn 0.3s ease-out',
|
| 242 |
+
}}>
|
| 243 |
+
<span style={{
|
| 244 |
+
fontSize: '10px',
|
| 245 |
+
color: msg.role === 'user' ? 'var(--green)' : '#6666ff',
|
| 246 |
+
letterSpacing: '1px',
|
| 247 |
+
fontWeight: 700,
|
| 248 |
+
display: 'block',
|
| 249 |
+
marginBottom: '4px',
|
| 250 |
+
}}>
|
| 251 |
+
{msg.role === 'user' ? 'TOI' : 'OPERATOR'}
|
| 252 |
+
</span>
|
| 253 |
+
{msg.text}
|
| 254 |
+
</div>
|
| 255 |
+
))}
|
| 256 |
+
|
| 257 |
+
{isThinking && (
|
| 258 |
+
<div style={{
|
| 259 |
+
padding: '10px 14px',
|
| 260 |
+
fontSize: '13px',
|
| 261 |
+
color: '#6666ff',
|
| 262 |
+
fontStyle: 'italic',
|
| 263 |
+
animation: 'pulse 1.5s infinite',
|
| 264 |
+
}}>
|
| 265 |
+
Operator reflechit...
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
+
<div ref={chatEndRef} />
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
{/* Input bar */}
|
| 273 |
+
<form
|
| 274 |
+
onSubmit={handleSubmit}
|
| 275 |
+
style={{
|
| 276 |
+
display: 'flex',
|
| 277 |
+
alignItems: 'center',
|
| 278 |
+
gap: '10px',
|
| 279 |
+
padding: '12px 24px 16px',
|
| 280 |
+
borderTop: '1px solid var(--border)',
|
| 281 |
+
}}
|
| 282 |
+
>
|
| 283 |
+
{hasSpeechAPI && (
|
| 284 |
+
<button
|
| 285 |
+
type="button"
|
| 286 |
+
onClick={toggleMic}
|
| 287 |
+
disabled={isThinking || isSpeaking}
|
| 288 |
+
style={{
|
| 289 |
+
width: '40px',
|
| 290 |
+
height: '40px',
|
| 291 |
+
borderRadius: '50%',
|
| 292 |
+
border: `2px solid ${isListening ? 'var(--red)' : '#444'}`,
|
| 293 |
+
background: isListening ? 'rgba(255, 68, 68, 0.15)' : 'transparent',
|
| 294 |
+
color: isListening ? 'var(--red)' : '#888',
|
| 295 |
+
fontSize: '16px',
|
| 296 |
+
cursor: isThinking || isSpeaking ? 'not-allowed' : 'pointer',
|
| 297 |
+
transition: 'all 0.2s',
|
| 298 |
+
opacity: isThinking || isSpeaking ? 0.4 : 1,
|
| 299 |
+
animation: isListening ? 'pulse 1s infinite' : 'none',
|
| 300 |
+
flexShrink: 0,
|
| 301 |
+
}}
|
| 302 |
+
>
|
| 303 |
+
{'\uD83C\uDF99'}
|
| 304 |
+
</button>
|
| 305 |
+
)}
|
| 306 |
+
|
| 307 |
+
<input
|
| 308 |
+
ref={inputRef}
|
| 309 |
+
type="text"
|
| 310 |
+
value={inputText}
|
| 311 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 312 |
+
disabled={isThinking || isSpeaking}
|
| 313 |
+
placeholder={isListening ? 'Ecoute en cours...' : 'Ecris ta question ou parle...'}
|
| 314 |
+
style={{
|
| 315 |
+
flex: 1,
|
| 316 |
+
background: '#1a1a1a',
|
| 317 |
+
border: '1px solid #333',
|
| 318 |
+
borderRadius: '4px',
|
| 319 |
+
padding: '10px 14px',
|
| 320 |
+
color: '#e0e0e0',
|
| 321 |
+
fontFamily: 'inherit',
|
| 322 |
+
fontSize: '13px',
|
| 323 |
+
outline: 'none',
|
| 324 |
+
}}
|
| 325 |
+
/>
|
| 326 |
+
|
| 327 |
+
<button
|
| 328 |
+
type="submit"
|
| 329 |
+
disabled={!inputText.trim() || isThinking || isSpeaking}
|
| 330 |
+
style={{
|
| 331 |
+
background: inputText.trim() && !isThinking ? 'var(--green)' : '#333',
|
| 332 |
+
color: '#0a0a0a',
|
| 333 |
+
border: 'none',
|
| 334 |
+
borderRadius: '4px',
|
| 335 |
+
padding: '10px 18px',
|
| 336 |
+
fontFamily: 'inherit',
|
| 337 |
+
fontSize: '12px',
|
| 338 |
+
fontWeight: 700,
|
| 339 |
+
letterSpacing: '1px',
|
| 340 |
+
cursor: !inputText.trim() || isThinking ? 'not-allowed' : 'pointer',
|
| 341 |
+
transition: 'all 0.2s',
|
| 342 |
+
flexShrink: 0,
|
| 343 |
+
}}
|
| 344 |
+
>
|
| 345 |
+
ENVOYER
|
| 346 |
+
</button>
|
| 347 |
+
</form>
|
| 348 |
+
</div>
|
| 349 |
+
);
|
| 350 |
+
}
|
frontend/src/hooks/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>
|