""" Chat IA Multi-Modèles — Serveur de synchronisation & proxy de génération (V9.4) ================================================================================ Ce serveur est prévu pour tourner dans un HuggingFace Space (16 Go RAM / 2 CPU, port 7860) et fait 3 choses : 1. Authentification (inscription / connexion) avec des tokens JWT. 2. Synchronisation de l'état complet du chat (chats, clés API, réglages) par utilisateur, stocké dans /data (volume persistant du Space). 3. Proxy de génération : le client n'appelle plus l'API du provider (OpenAI, Groq, Gemini, custom) directement. Il demande au serveur de le faire. Le serveur lance une tâche asyncio EN ARRIÈRE-PLAN, indépendante de la requête HTTP du client : si le client se déconnecte (ferme l'onglet, perd le réseau, change d'appareil), la génération continue côté serveur et le texte est accumulé en mémoire (+ persisté à la fin dans /data). N'importe quel appareil connecté au même compte peut alors se rebrancher sur le flux (SSE) en cours ou récupérer le résultat déjà terminé. Stockage disque (dans /data) : /data/users.json -> {username: {password_hash, created_at}} /data/states/.json -> état complet du chat de cet utilisateur /data/secret.key -> clé secrète JWT (générée une fois, persistée) Tout est volontairement simple (fichiers JSON + verrous asyncio) : c'est largement suffisant pour un usage personnel / petit groupe d'utilisateurs sur un Space à 2 CPU. Pas de base de données externe requise. """ import asyncio import json import os import secrets import time import uuid from pathlib import Path from typing import Optional from urllib.parse import urljoin import bcrypt import httpx import jwt from fastapi import FastAPI, Header, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel # -------------------------------------------------------------------------- # Configuration & stockage # -------------------------------------------------------------------------- DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) USERS_DIR = DATA_DIR STATES_DIR = DATA_DIR / "states" SECRET_FILE = DATA_DIR / "secret.key" USERS_FILE = DATA_DIR / "users.json" DATA_DIR.mkdir(parents=True, exist_ok=True) STATES_DIR.mkdir(parents=True, exist_ok=True) if not SECRET_FILE.exists(): SECRET_FILE.write_text(secrets.token_hex(32)) JWT_SECRET = SECRET_FILE.read_text().strip() JWT_ALGO = "HS256" JWT_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 jours EMPTY_STATE = { "chats": [], "activeChatId": None, "virtualFiles": {}, "currentImage": None, "currentTextFile": None, "keys": {"openai": "", "groq": "", "gemini": "", "deepai": "", "custom": []}, "settings": {"webSearch": False, "fileSystem": False, "visionModel": "", "typeSpeed": 18}, } # Grâce à ce verrou par utilisateur, deux écritures concurrentes sur le même # fichier d'état ne se corrompent pas mutuellement. _user_locks: dict[str, asyncio.Lock] = {} _users_lock = asyncio.Lock() def get_user_lock(username: str) -> asyncio.Lock: if username not in _user_locks: _user_locks[username] = asyncio.Lock() return _user_locks[username] def _atomic_write_json(path: Path, data: dict): tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False)) os.replace(tmp, path) async def load_users() -> dict: async with _users_lock: if not USERS_FILE.exists(): return {} try: return json.loads(USERS_FILE.read_text()) except Exception: return {} async def save_users(users: dict): async with _users_lock: _atomic_write_json(USERS_FILE, users) def state_path(username: str) -> Path: safe = "".join(c for c in username if c.isalnum() or c in ("-", "_")) or "user" return STATES_DIR / f"{safe}.json" async def load_state(username: str) -> dict: path = state_path(username) if not path.exists(): return json.loads(json.dumps(EMPTY_STATE)) try: data = json.loads(path.read_text()) except Exception: return json.loads(json.dumps(EMPTY_STATE)) for k, v in EMPTY_STATE.items(): if k not in data: data[k] = v return data async def save_state(username: str, data: dict): async with get_user_lock(username): _atomic_write_json(state_path(username), data) # -------------------------------------------------------------------------- # Auth # -------------------------------------------------------------------------- def make_token(username: str) -> str: payload = {"sub": username, "exp": int(time.time()) + JWT_TTL_SECONDS} return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO) def verify_token(authorization: Optional[str]) -> str: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Token manquant") token = authorization[len("Bearer "):] try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Session expirée, reconnectez-vous") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Token invalide") return payload["sub"] class AuthBody(BaseModel): username: str password: str class SyncBody(BaseModel): data: dict # -------------------------------------------------------------------------- # Jobs de génération (proxy streaming résilient) # -------------------------------------------------------------------------- # jobs[job_id] = { # username, chat_id, message_id, provider, model, # buffer, status, error, tokens, promptTokens, completionTokens, # created_at, updated_at, cancel_event # } JOBS: dict[str, dict] = {} JOB_GRACE_SECONDS = 60 * 30 # on garde un job terminé 30 min pour permettre une reprise tardive def new_job(username: str, chat_id, message_id: str, provider: str, model: str) -> str: job_id = uuid.uuid4().hex JOBS[job_id] = { "id": job_id, "username": username, "chat_id": chat_id, "message_id": message_id, "provider": provider, "model": model, "buffer": "", "status": "running", "error": None, "tokens": None, "promptTokens": None, "completionTokens": None, "created_at": time.time(), "updated_at": time.time(), "cancel_event": asyncio.Event(), "status_text": None, # statut agentique transitoire (ex: recherche web en cours) } return job_id async def persist_job_result(job: dict): """Écrit le contenu final du message dans l'état persistant de l'utilisateur, et crée le chat/message s'il n'existe pas encore côté serveur (évite une course avec le debounce de /sync côté client).""" username = job["username"] async with get_user_lock(username): state = await load_state(username) chat = next((c for c in state["chats"] if str(c.get("id")) == str(job["chat_id"])), None) if chat is None: chat = { "id": job["chat_id"], "title": "Nouvelle discussion", "messages": [], "model": job["model"], "provider": job["provider"], "extraPricing": None, } state["chats"].insert(0, chat) msg = next((m for m in chat["messages"] if m.get("id") == job["message_id"]), None) if msg is None: msg = {"id": job["message_id"], "role": "assistant", "content": ""} chat["messages"].append(msg) msg["content"] = job["buffer"] msg["isThinking"] = False if job["tokens"]: msg["tokens"] = job["tokens"] if job["promptTokens"]: msg["promptTokens"] = job["promptTokens"] if job["completionTokens"]: msg["completionTokens"] = job["completionTokens"] if job["status"] == "error" and job["error"]: msg["content"] = (msg["content"] or "") + f"\n**Erreur API:** {job['error']}" _atomic_write_json(state_path(username), state) async def upsert_pending_message(username: str, chat_id, chat_title: str, recent_messages: list): """Enregistre immédiatement le message utilisateur + le placeholder assistant (appelé au lancement du job) pour que le chat existe côté serveur sans attendre le prochain /sync du client.""" async with get_user_lock(username): state = await load_state(username) chat = next((c for c in state["chats"] if str(c.get("id")) == str(chat_id)), None) if chat is None: chat = { "id": chat_id, "title": chat_title or "Nouvelle discussion", "messages": [], "model": "", "provider": "", "extraPricing": None, } state["chats"].insert(0, chat) existing_ids = {m.get("id") for m in chat["messages"]} for m in recent_messages or []: if m.get("id") and m["id"] not in existing_ids: chat["messages"].append(m) existing_ids.add(m["id"]) _atomic_write_json(state_path(username), state) def _sse(event: str, data: dict) -> bytes: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8") async def run_openai_like(job: dict, url: str, api_key: str, model: str, system: str, messages: list): headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" full_messages = [{"role": "system", "content": system}] + [ {"role": m.get("role", "user"), "content": m.get("content", "")} for m in messages ] body = {"model": model, "messages": full_messages, "stream": True, "stream_options": {"include_usage": True}} async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client: async with client.stream("POST", url, headers=headers, json=body) as resp: if resp.status_code >= 400: err_text = (await resp.aread()).decode("utf-8", "ignore") raise RuntimeError(f"HTTP {resp.status_code}: {err_text[:500]}") async for line in resp.aiter_lines(): if job["cancel_event"].is_set(): return line = line.strip() if not line.startswith("data:"): continue data_str = line[5:].strip() if data_str == "[DONE]": continue try: data = json.loads(data_str) except Exception: continue choices = data.get("choices") or [] if choices: delta = (choices[0].get("delta") or {}).get("content") if delta: job["buffer"] += delta job["updated_at"] = time.time() usage = data.get("usage") if usage: job["tokens"] = usage.get("total_tokens") or job["tokens"] job["promptTokens"] = usage.get("prompt_tokens") or job["promptTokens"] job["completionTokens"] = usage.get("completion_tokens") or job["completionTokens"] async def run_gemini(job: dict, api_key: str, model: str, system: str, messages: list): url = ( f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent" f"?key={api_key}&alt=sse" ) contents = [ {"role": "user" if m.get("role") == "user" else "model", "parts": [{"text": m.get("content", "")}]} for m in messages ] body = {"contents": contents, "systemInstruction": {"parts": [{"text": system}]}} headers = {"Content-Type": "application/json"} async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client: async with client.stream("POST", url, headers=headers, json=body) as resp: if resp.status_code >= 400: err_text = (await resp.aread()).decode("utf-8", "ignore") raise RuntimeError(f"HTTP {resp.status_code}: {err_text[:500]}") async for line in resp.aiter_lines(): if job["cancel_event"].is_set(): return line = line.strip() if not line.startswith("data:"): continue data_str = line[5:].strip() if not data_str: continue try: data = json.loads(data_str) except Exception: continue candidates = data.get("candidates") or [] if candidates: parts = (candidates[0].get("content") or {}).get("parts") or [] if parts and parts[0].get("text"): job["buffer"] += parts[0]["text"] job["updated_at"] = time.time() usage = data.get("usageMetadata") if usage: job["tokens"] = usage.get("candidatesTokenCount") or usage.get("totalTokenCount") or job["tokens"] job["promptTokens"] = usage.get("promptTokenCount") or job["promptTokens"] job["completionTokens"] = usage.get("candidatesTokenCount") or job["completionTokens"] async def run_deepai(job: dict, api_key: str, system: str, messages: list): prompt = f"system: {system}\n" for m in messages: prompt += f"{m.get('role','user')}: {m.get('content','')}\n" async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=30.0)) as client: resp = await client.post( "https://api.deepai.org/api/text-generator", headers={"api-key": api_key}, data={"text": prompt}, ) data = resp.json() job["buffer"] = data.get("output") or data.get("err") or "Erreur DeepAI" job["updated_at"] = time.time() # -------------------------------------------------------------------------- # Recherche Web agentique — serveur MCP communautaire "victor/websearch" (HF Space) # https://huggingface.co/spaces/victor/websearch # -------------------------------------------------------------------------- # # Flux : 1) un petit appel non-streamé au modèle choisi par l'utilisateur décide # si une recherche est utile et propose 1 à 3 requêtes ; 2) ces requêtes sont # envoyées EN PARALLÈLE (asyncio.gather) au serveur MCP, qui interroge lui-même # plusieurs sites web par requête ; 3) les résultats sont injectés dans le # system prompt avant l'appel réel au modèle. Tout ceci reste invisible pour # l'utilisateur, qui ne voit qu'un court statut ("Je vais rechercher..."). WEBSEARCH_MCP_SSE_URL = "https://victor-websearch.hf.space/gradio_api/mcp/sse" class MCPSSEClient: """Client MCP minimal (transport SSE, JSON-RPC 2.0) pour interroger un serveur MCP externe. Une instance = une session courte, ouverte pour la durée d'une recherche puis refermée (pas de session partagée entre utilisateurs).""" def __init__(self, sse_url: str): self.sse_url = sse_url self.post_url: Optional[str] = None self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=15.0)) self._futures: dict[int, asyncio.Future] = {} self._id_counter = 0 self._ready = asyncio.Event() self._stream_task: Optional[asyncio.Task] = None async def start(self): self._stream_task = asyncio.create_task(self._read_stream()) await asyncio.wait_for(self._ready.wait(), timeout=15) await self._request("initialize", { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "chatia-multi-modeles", "version": "9.5"}, }) await self._notify("notifications/initialized", {}) async def _read_stream(self): try: async with self._client.stream("GET", self.sse_url, headers={"Accept": "text/event-stream"}) as resp: event_name, data_lines = None, [] async for line in resp.aiter_lines(): if line.startswith(":"): continue if line.startswith("event:"): event_name = line[6:].strip() elif line.startswith("data:"): data_lines.append(line[5:].strip()) elif line.strip() == "": if data_lines: self._handle_event(event_name, "\n".join(data_lines)) event_name, data_lines = None, [] except Exception: pass finally: for fut in self._futures.values(): if not fut.done(): fut.set_exception(RuntimeError("Connexion MCP fermée prématurément")) def _handle_event(self, event_name, data): if event_name == "endpoint": self.post_url = urljoin(self.sse_url, data) self._ready.set() return try: msg = json.loads(data) except Exception: return mid = msg.get("id") if mid is not None and mid in self._futures and not self._futures[mid].done(): self._futures[mid].set_result(msg) async def _request(self, method, params, timeout=25.0): self._id_counter += 1 mid = self._id_counter fut = asyncio.get_event_loop().create_future() self._futures[mid] = fut try: resp = await self._client.post(self.post_url, json={"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) if resp.status_code >= 400: raise RuntimeError(f"MCP HTTP {resp.status_code}") msg = await asyncio.wait_for(fut, timeout=timeout) finally: self._futures.pop(mid, None) if "error" in msg: raise RuntimeError(f"Erreur MCP: {msg['error']}") return msg.get("result") async def _notify(self, method, params): await self._client.post(self.post_url, json={"jsonrpc": "2.0", "method": method, "params": params}) async def list_tools(self): result = await self._request("tools/list", {}) return (result or {}).get("tools", []) async def call_tool(self, name, arguments): return await self._request("tools/call", {"name": name, "arguments": arguments}, timeout=40.0) async def close(self): if self._stream_task: self._stream_task.cancel() await self._client.aclose() def _pick_search_tool(tools: list) -> Optional[dict]: for t in tools: blob = ((t.get("name") or "") + " " + (t.get("description") or "")).lower() if "search" in blob: return t return tools[0] if tools else None def _match_arg(props: dict, candidates: list) -> Optional[str]: lower_map = {k.lower(): k for k in props} for c in candidates: if c.lower() in lower_map: return lower_map[c.lower()] return None def _extract_mcp_text(result) -> str: if not result: return "" content = result.get("content") if isinstance(result, dict) else None if not content: return json.dumps(result, ensure_ascii=False)[:4000] parts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"] return "\n".join(parts)[:6000] # borne la taille injectée dans le contexte du modèle async def run_web_search(queries: list, search_type: str = "search", num_results: int = 4) -> str: """Interroge le serveur MCP de victor avec plusieurs requêtes EN PARALLÈLE (donc potentiellement plusieurs sites différents par requête, sur plusieurs requêtes à la fois) et retourne un texte agrégé prêt à injecter dans le contexte du modèle.""" client = MCPSSEClient(WEBSEARCH_MCP_SSE_URL) try: await client.start() tools = await client.list_tools() tool = _pick_search_tool(tools) if not tool: return "" tool_name = tool["name"] props = ((tool.get("inputSchema") or {}).get("properties")) or {} query_key = _match_arg(props, ["query", "q", "search_query", "text"]) or "query" type_key = _match_arg(props, ["search_type", "type", "mode"]) num_key = _match_arg(props, ["num_results", "n", "count", "max_results", "limit"]) async def one_search(q: str): args = {query_key: q} if type_key: args[type_key] = search_type if num_key: args[num_key] = num_results try: result = await client.call_tool(tool_name, args) return q, _extract_mcp_text(result) except Exception as e: return q, f"[Erreur de recherche pour « {q} » : {e}]" results = await asyncio.gather(*(one_search(q) for q in queries)) return "\n\n".join(f"### Résultats pour : {q}\n{text}" for q, text in results if text) finally: await client.close() async def run_single_completion(provider: str, model: str, api_key: str, base_url: str, system: str, user_text: str, max_tokens: int = 250) -> str: """Appel non-streamé, court, utilisé uniquement pour la décision agentique ("faut-il chercher sur le web, et quoi ?"). Ne consomme pas le buffer du job.""" async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client: if provider == "gemini": url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" body = { "contents": [{"role": "user", "parts": [{"text": user_text}]}], "systemInstruction": {"parts": [{"text": system}]}, "generationConfig": {"maxOutputTokens": max_tokens, "temperature": 0}, } resp = await client.post(url, json=body) resp.raise_for_status() data = resp.json() return data["candidates"][0]["content"]["parts"][0]["text"] if provider == "openai": url = "https://api.openai.com/v1/chat/completions" elif provider == "groq": url = "https://api.groq.com/openai/v1/chat/completions" elif provider == "custom": url = base_url.rstrip("/") + "/chat/completions" else: raise ValueError(f"Provider non supporté pour la décision de recherche : {provider}") headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" body = { "model": model, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user_text}], "max_tokens": max_tokens, "temperature": 0, "stream": False, } resp = await client.post(url, headers=headers, json=body) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"] WEB_SEARCH_DECISION_PROMPT = ( "Tu es un module de décision pour un assistant IA. Réponds UNIQUEMENT avec un objet JSON, " "sans aucun texte ni markdown autour, au format exact : " '{"search": true, "queries": ["...", "..."]} ou {"search": false}. ' "Mets \"search\" à true seulement si une recherche web est vraiment utile pour bien répondre " "(actualité récente, informations qui changent dans le temps, faits précis et vérifiables). " "Si search est true, propose entre 1 et 3 requêtes de recherche courtes et précises, " "éventuellement sous des angles complémentaires (ex: sites différents, formulations différentes) " "pour couvrir plusieurs sources à la fois." ) async def decide_search_queries(provider: str, model: str, api_key: str, base_url: str, user_text: str) -> list: if provider == "deepai" or not user_text.strip(): return [] try: raw = (await run_single_completion(provider, model, api_key, base_url, WEB_SEARCH_DECISION_PROMPT, user_text)).strip() if raw.startswith("```"): raw = raw.strip("`") if "\n" in raw: raw = raw.split("\n", 1)[1] data = json.loads(raw) if data.get("search") and isinstance(data.get("queries"), list): return [q.strip() for q in data["queries"] if isinstance(q, str) and q.strip()][:3] except Exception as e: print(f"[WARN] Décision de recherche web échouée (on continue sans recherche) : {e}") return [] async def maybe_run_agentic_web_search(job: dict, provider: str, model: str, api_key: str, base_url: str, system: str, messages: list) -> str: """Retourne le system prompt éventuellement enrichi des résultats de recherche. Met à jour job["status_text"] pour que le client affiche un statut ("Je vais rechercher...") pendant cette étape, sans jamais exposer le détail des tool calls.""" last_user_text = "" for m in reversed(messages): if m.get("role") == "user": last_user_text = m.get("content", "") break if not last_user_text: return system job["status_text"] = "Je réfléchis à si une recherche web est nécessaire..." queries = await decide_search_queries(provider, model, api_key, base_url, last_user_text) if not queries: job["status_text"] = None return system job["status_text"] = "Je vais rechercher sur le web, pour vous fournir une réponse complète..." try: results_text = await run_web_search(queries) except Exception as e: print(f"[WARN] Recherche web échouée : {e}") results_text = "" job["status_text"] = None if not results_text: return system return ( system + "\n\n--- Résultats de recherche web (obtenus juste avant ta réponse, plusieurs sites interrogés) ---\n" + results_text + "\n--- Fin des résultats de recherche web ---\n" + "Utilise ces informations pour répondre de façon complète et à jour. " + "Ne mentionne pas explicitement que tu as \"utilisé un outil\" ou \"MCP\" ; réponds naturellement." ) async def run_job(job_id: str, params: dict): job = JOBS[job_id] try: provider = params["provider"] model = params["model"] system = params.get("system", "") messages = params.get("messages", []) api_key = params.get("apiKey", "") base_url = params.get("baseUrl", "") if params.get("webSearchEnabled"): system = await maybe_run_agentic_web_search(job, provider, model, api_key, base_url, system, messages) if provider == "openai": await run_openai_like(job, "https://api.openai.com/v1/chat/completions", api_key, model, system, messages) elif provider == "groq": await run_openai_like(job, "https://api.groq.com/openai/v1/chat/completions", api_key, model, system, messages) elif provider == "custom": url = base_url.rstrip("/") + "/chat/completions" await run_openai_like(job, url, api_key, model, system, messages) elif provider == "gemini": await run_gemini(job, api_key, model, system, messages) elif provider == "deepai": await run_deepai(job, api_key, system, messages) else: raise ValueError(f"Provider inconnu: {provider}") job["status"] = "cancelled" if job["cancel_event"].is_set() else "done" except Exception as e: job["status"] = "error" job["error"] = str(e) finally: job["status_text"] = None job["updated_at"] = time.time() try: await persist_job_result(job) except Exception as e: print(f"[WARN] Échec de persistance du job {job_id}: {e}") async def cleanup_jobs_loop(): while True: await asyncio.sleep(300) now = time.time() to_delete = [ jid for jid, j in JOBS.items() if j["status"] != "running" and (now - j["updated_at"]) > JOB_GRACE_SECONDS ] for jid in to_delete: JOBS.pop(jid, None) # -------------------------------------------------------------------------- # FastAPI app # -------------------------------------------------------------------------- app = FastAPI(title="Chat IA - Serveur de Sync V9.4") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") async def on_startup(): asyncio.create_task(cleanup_jobs_loop()) # ---- Auth ---- @app.post("/register") async def register(body: AuthBody): username = body.username.strip() if not username or not body.password: raise HTTPException(status_code=400, detail="Champs requis.") if len(body.password) < 4: raise HTTPException(status_code=400, detail="Mot de passe trop court (4 caractères min).") users = await load_users() if username in users: raise HTTPException(status_code=409, detail="Ce nom d'utilisateur existe déjà.") pw_hash = bcrypt.hashpw(body.password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") users[username] = {"password_hash": pw_hash, "created_at": time.time()} await save_users(users) await save_state(username, json.loads(json.dumps(EMPTY_STATE))) return {"ok": True} @app.post("/login") async def login(body: AuthBody): username = body.username.strip() users = await load_users() user = users.get(username) if not user or not bcrypt.checkpw(body.password.encode("utf-8"), user["password_hash"].encode("utf-8")): raise HTTPException(status_code=401, detail="Identifiants incorrects.") return {"token": make_token(username)} # ---- Sync ---- @app.get("/sync") async def get_sync(authorization: Optional[str] = Header(None)): username = verify_token(authorization) data = await load_state(username) return {"data": data} @app.post("/sync") async def post_sync(body: SyncBody, authorization: Optional[str] = Header(None)): username = verify_token(authorization) await save_state(username, body.data) return {"ok": True} class BeaconSyncBody(BaseModel): token: str data: dict @app.post("/sync/beacon") async def post_sync_beacon(body: BeaconSyncBody): # navigator.sendBeacon() ne permet pas de fixer un header Authorization, # le token est donc transmis dans le corps de la requête pour ce cas précis # (utilisé uniquement comme filet de sécurité à la fermeture de l'onglet). username = verify_token(f"Bearer {body.token}") await save_state(username, body.data) return {"ok": True} # ---- Génération (proxy streaming résilient) ---- class GenerateBody(BaseModel): chat_id: object chat_title: Optional[str] = "" message_id: str provider: str model: str apiKey: Optional[str] = "" baseUrl: Optional[str] = "" system: Optional[str] = "" messages: list recent_messages: Optional[list] = None webSearchEnabled: Optional[bool] = False @app.post("/api/generate") async def start_generate(body: GenerateBody, authorization: Optional[str] = Header(None)): username = verify_token(authorization) if body.recent_messages: await upsert_pending_message(username, body.chat_id, body.chat_title, body.recent_messages) job_id = new_job(username, body.chat_id, body.message_id, body.provider, body.model) params = { "provider": body.provider, "model": body.model, "system": body.system or "", "messages": body.messages, "apiKey": body.apiKey or "", "baseUrl": body.baseUrl or "", "webSearchEnabled": bool(body.webSearchEnabled), } asyncio.create_task(run_job(job_id, params)) return {"job_id": job_id} @app.post("/api/generate/cancel/{job_id}") async def cancel_generate(job_id: str, authorization: Optional[str] = Header(None)): username = verify_token(authorization) job = JOBS.get(job_id) if not job or job["username"] != username: raise HTTPException(status_code=404, detail="Job introuvable") job["cancel_event"].set() return {"ok": True} @app.get("/api/generate/active") async def active_jobs(authorization: Optional[str] = Header(None)): username = verify_token(authorization) jobs = [ {"job_id": j["id"], "chat_id": j["chat_id"], "message_id": j["message_id"], "provider": j["provider"], "model": j["model"]} for j in JOBS.values() if j["username"] == username and j["status"] == "running" ] return {"jobs": jobs} @app.get("/api/generate/stream/{job_id}") async def stream_generate(job_id: str, authorization: Optional[str] = Header(None)): username = verify_token(authorization) job = JOBS.get(job_id) if not job or job["username"] != username: raise HTTPException(status_code=404, detail="Job introuvable ou expiré") async def event_gen(): # 1) Rattrapage immédiat de ce qui a déjà été généré. sent_len = len(job["buffer"]) yield _sse("sync", {"content": job["buffer"], "tokens": job.get("tokens")}) last_status_sent = None if job.get("status_text"): last_status_sent = job["status_text"] yield _sse("status", {"text": last_status_sent}) # 2) Puis on suit les nouveaux morceaux au fur et à mesure (+ les statuts # agentiques transitoires, ex: "Je vais rechercher sur le web..."). while True: await asyncio.sleep(0.1) current_status = job.get("status_text") if current_status != last_status_sent: last_status_sent = current_status yield _sse("status", {"text": current_status or ""}) current = job["buffer"] if len(current) > sent_len: new_text = current[sent_len:] sent_len = len(current) yield _sse("delta", {"text": new_text}) if job["status"] != "running": # on vide le dernier reste éventuel avant de conclure current = job["buffer"] if len(current) > sent_len: yield _sse("delta", {"text": current[sent_len:]}) sent_len = len(current) if job["status"] == "error": yield _sse("error", {"message": job.get("error") or "Erreur inconnue"}) else: yield _sse("done", { "tokens": job.get("tokens"), "promptTokens": job.get("promptTokens"), "completionTokens": job.get("completionTokens"), }) break return StreamingResponse( event_gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, ) # ---- Fichiers statiques : sert le client HTML directement depuis le Space ---- STATIC_DIR = Path(__file__).parent / "static" if STATIC_DIR.exists(): app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets") @app.get("/") async def serve_index(): index_file = STATIC_DIR / "index.html" if index_file.exists(): return FileResponse(str(index_file)) return JSONResponse({"status": "ok", "info": "Placez V9_4.html dans server/static/index.html"}) @app.get("/health") async def health(): return {"status": "ok", "jobs_running": sum(1 for j in JOBS.values() if j["status"] == "running")}