Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
12.8 kB
import axios from "axios";
const BACKEND_URL = (process.env.REACT_APP_BACKEND_URL || "").replace(/\/$/, "");
const BACKEND_FALLBACK = (process.env.REACT_APP_BACKEND_FALLBACK_URL || "").replace(/\/$/, "");
export { BACKEND_URL, BACKEND_FALLBACK };
/** Base URL active après wake / fallback (sans /api). */
let activeBase = BACKEND_URL || "";
export function getActiveBase() {
return activeBase || BACKEND_URL || "";
}
export function getApiBase() {
const base = getActiveBase();
return base ? `${base}/api` : "/api";
}
/** @deprecated Préférer getApiBase() — suit le backend actif après wake. */
export const API = BACKEND_URL ? `${BACKEND_URL}/api` : "/api";
const SESSION_KEY = "emo_session_token";
/** Dernière sonde API réussie (évite wake inutile avant login). */
let apiReachable = false;
export function isApiReachable() {
return apiReachable;
}
const RETRY_STATUSES = new Set([429, 502, 503, 504]);
const AUTH_MAX_ATTEMPTS = 8;
function sleep(ms) {
return new Promise((res) => setTimeout(res, ms));
}
function isRetriableStatus(status) {
return Boolean(status && RETRY_STATUSES.has(status));
}
export function formatApiError(err, fallback = "Erreur réseau") {
const status = err?.response?.status;
const detail = err?.response?.data?.detail;
if (status === 429) return "API saturée (Hugging Face). Attendez 2 min puis réessayez.";
if (status === 401 || status === 403) {
return typeof detail === "string" ? detail : "Identifiants incorrects";
}
if (typeof detail === "string") return detail;
if (!err?.response) {
return "API injoignable. Le serveur HF démarre peut‑être — attendez 1 min puis réessayez.";
}
return err?.message || fallback;
}
/** Requêtes auth avec retries (HF cold start / 429 uniquement). */
export async function authRequest(requestFn, options = {}) {
const maxAttempts = options.maxAttempts ?? 4;
let lastErr;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await requestFn();
} catch (err) {
lastErr = err;
const status = err?.response?.status;
if (!status || !isRetriableStatus(status)) break;
if (attempt >= maxAttempts - 1) break;
await sleep(status === 429 ? 2500 + attempt * 1200 : 1200);
}
}
throw lastErr;
}
function backendCandidates() {
const seen = new Set();
const out = [];
for (const b of [activeBase, BACKEND_URL, BACKEND_FALLBACK]) {
if (b && !seen.has(b)) {
seen.add(b);
out.push(b);
}
}
if (!out.length) out.push("");
return out;
}
async function probePing(base, timeoutMs = 8000) {
const url = base ? `${base}/api/ping` : "/api/ping";
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, {
credentials: "omit",
cache: "no-store",
signal: ctrl.signal,
});
if (r.ok) {
const data = await r.json().catch(() => ({}));
apiReachable = true;
return { base: base || "same-origin", google: !!data.google, waking: false };
}
// HF cold start / rate limit : le serveur répond quand même — ne pas bloquer le login 90s
if (RETRY_STATUSES.has(r.status) || r.status === 429) {
if (base) activeBase = base;
apiReachable = true;
return { base: base || "same-origin", google: false, waking: true };
}
return null;
} catch (_) {
return null;
} finally {
clearTimeout(timer);
}
}
async function _fetchWithFallback(path, options = {}) {
let lastErr;
for (const base of backendCandidates()) {
const url = base ? `${base}/api${path}` : `/api${path}`;
try {
const r = await fetch(url, options);
if (RETRY_STATUSES.has(r.status)) {
await sleep(1500);
continue;
}
if (r.ok && base) activeBase = base;
return r;
} catch (e) {
lastErr = e;
}
}
throw lastErr || new Error("Service indisponible");
}
export function saveSessionToken(token) {
if (!token) return;
try { localStorage.setItem(SESSION_KEY, token); } catch (_) {}
}
export function clearSessionToken() {
try { localStorage.removeItem(SESSION_KEY); } catch (_) {}
}
export function getSessionToken() {
try { return localStorage.getItem(SESSION_KEY) || ""; } catch (_) { return ""; }
}
/** POST JSON via fetch — plus fiable que axios pour l'auth cross-origin (HF). */
export async function apiPostJson(path, data, options = {}) {
const timeout = options.timeout ?? 20000;
const base = getActiveBase() || BACKEND_URL;
if (!base) throw new Error("API non configurée");
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
const token = getSessionToken();
const headers = { "Content-Type": "application/json", Accept: "application/json" };
if (token) {
headers.Authorization = `Bearer ${token}`;
headers["X-Emo-Session"] = token;
}
try {
const res = await fetch(`${base}/api${path}`, {
method: "POST",
credentials: "omit",
headers,
body: JSON.stringify(data),
signal: ctrl.signal,
});
activeBase = base;
apiReachable = true;
let json = {};
try { json = await res.json(); } catch (_) {}
if (!res.ok) {
const err = new Error(typeof json.detail === "string" ? json.detail : "Erreur API");
err.response = { status: res.status, data: json };
throw err;
}
return { data: json, status: res.status };
} catch (e) {
if (e?.name === "AbortError") {
const err = new Error("Délai dépassé — le serveur HF est lent.");
err.response = null;
throw err;
}
throw e;
} finally {
clearTimeout(timer);
}
}
export const http = axios.create({
baseURL: getApiBase(),
withCredentials: false,
timeout: 45000,
});
http.interceptors.request.use((config) => {
config.baseURL = getApiBase();
const token = getSessionToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
config.headers["X-Emo-Session"] = token;
}
return config;
});
http.interceptors.response.use(
(res) => {
const base = (res.config.baseURL || "").replace(/\/api\/?$/, "");
if (base && base !== "/") activeBase = base;
return res;
},
async (err) => {
const cfg = err.config || {};
const status = err.response?.status;
const retries = cfg._emoRetryCount || 0;
const maxRetries = cfg._emoMaxRetries ?? 4;
const skipRetry = cfg._emoSkipRetry === true;
const canRetry = !skipRetry && retries < maxRetries && status && isRetriableStatus(status);
if (canRetry) {
const bases = backendCandidates();
const current = getActiveBase();
const next = bases.find((b) => b && b !== current) || bases[0];
if (next !== undefined) {
cfg._emoRetryCount = retries + 1;
activeBase = next || activeBase;
cfg.baseURL = getApiBase();
await sleep(status === 429 ? 2200 + retries * 900 : 800);
return http.request(cfg);
}
}
if (status === 429) {
err.message = "API saturée (Hugging Face). Attendez 2 min puis réessayez.";
} else if (!err.response) {
err.message = "API injoignable. Le serveur HF démarre peut‑être — attendez 1 min puis réessayez.";
}
// Session invalide/expirée sur un appel authentifié : on purge le token
// et on renvoie au login, sinon l'app reste dans un état « phantom » où
// chaque action échoue silencieusement (création de conversation, etc.).
// On ignore les endpoints d'auth eux-mêmes (login/signup), qui renvoient
// légitimement 401 sur un mauvais mot de passe.
if (status === 401 && typeof window !== "undefined") {
const url = cfg.url || "";
const isAuthEndpoint = /\/auth\/(login|signup|google|me)/.test(url);
const reqToken = (
(cfg.headers?.Authorization || "").replace(/^Bearer\s+/i, "").trim()
|| cfg.headers?.["X-Emo-Session"]
|| ""
);
const current = getSessionToken();
// Ignore stale /auth/me responses that raced with a fresh login.
if (reqToken && current && reqToken !== current) {
return Promise.reject(err);
}
if (!isAuthEndpoint && current) {
clearSessionToken();
const cur = window.location.pathname || "";
if (cur && cur !== "/login") {
window.location.replace(`${process.env.PUBLIC_URL || ""}/login`.replace(/\/+/g, "/") || "/login");
}
}
}
return Promise.reject(err);
}
);
export async function streamChat({ conversation_id, content, images, image_media_types, mode, model_preference, use_agent_tools, agent_project_path, onEvent, signal }) {
const headers = { "Content-Type": "application/json", Accept: "text/event-stream" };
const token = getSessionToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
headers["X-Emo-Session"] = token;
}
let terminal = false;
const finish = (evt) => {
if (evt?.type === "done" || evt?.type === "error" || evt?.type === "cancelled") terminal = true;
onEvent?.(evt);
};
let resp;
try {
resp = await _fetchWithFallback("/chat/stream", {
method: "POST",
credentials: "omit",
headers,
signal,
body: JSON.stringify({
conversation_id,
content,
images: images?.length ? images : undefined,
image_media_types: image_media_types?.length ? image_media_types : undefined,
mode,
model_preference: model_preference || "auto",
use_agent_tools: use_agent_tools !== false,
agent_project_path: agent_project_path?.trim() || undefined,
}),
});
} catch (e) {
if (e?.name === "AbortError") {
finish({ type: "cancelled" });
return;
}
finish({ type: "error", content: "Connexion impossible." });
return;
}
if (resp.status === 429) {
finish({ type: "error", content: "Service saturé. Réessayez." });
return;
}
if (resp.status === 401 || resp.status === 403) {
// Token invalide/expiré : on purge la session pour forcer le re-login,
// sinon chaque message échoue silencieusement (effet « phantom »).
clearSessionToken();
finish({ type: "auth_error", content: "Session expirée — reconnectez-vous." });
return;
}
if (!resp.ok) {
let msg = "Une erreur est survenue.";
try {
const err = await resp.json();
msg = err.detail?.message || err.detail || err.message || msg;
if (typeof msg === "object") msg = msg.message || JSON.stringify(msg);
} catch (_) {}
finish({ type: "error", content: msg });
return;
}
if (!resp.body) {
finish({ type: "error", content: "Réponse vide du serveur." });
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() || "";
for (const p of parts) {
const line = p.trim();
if (!line.startsWith("data:")) continue;
const json = line.slice(5).trim();
if (!json) continue;
try {
finish(JSON.parse(json));
} catch (_) {
// ignore malformed
}
}
}
if (!terminal) {
finish({ type: "error", content: "Réponse interrompue." });
}
} catch (e) {
if (e?.name === "AbortError") {
finish({ type: "cancelled" });
return;
}
finish({
type: "error",
content: e?.message?.includes("network") || e?.name === "TypeError"
? "Connexion perdue."
: (e?.message || "Erreur de connexion"),
});
}
}
const BOOT_MESSAGE = "Chargement…";
export async function wakeBackend(options = {}) {
const maxWaitMs = options.maxWaitMs ?? 35000;
const onProgress = options.onProgress;
const start = Date.now();
let attempt = 0;
let sawWaking = false;
while (Date.now() - start < maxWaitMs) {
attempt += 1;
onProgress?.({
attempt,
elapsed: Date.now() - start,
message: BOOT_MESSAGE,
});
for (const base of backendCandidates()) {
const hit = await probePing(base, attempt <= 1 ? 8000 : 5000);
if (hit) {
if (base) activeBase = base;
if (hit.waking) sawWaking = true;
return {
ok: true,
google: !!hit.google,
base: hit.base,
waking: !!hit.waking,
};
}
}
const wait = Math.min(2000 + attempt * 600, 8000);
await sleep(wait);
}
if (sawWaking) {
return { ok: true, google: false, waking: true };
}
return { ok: false };
}