Spaces:
Running
Running
| /** | |
| * RASAD FastAPI client. | |
| * | |
| * The backend (Python FastAPI) lives separately from Supabase. Auth is still | |
| * handled by Supabase; only verification calls flow through this adapter. | |
| * | |
| * Resolution order for the API base URL: | |
| * 1. VITE_RASAD_API_URL (env var) โ explicit override wins | |
| * 2. localhost / 127.0.0.1 โ dev โ http://localhost:8000/api/v1 | |
| * 3. anything else (vercel, netlify, โฆ) โ prod โ Render-deployed backend | |
| * | |
| * This means a `git clone` works for both: | |
| * โข Judges running everything locally โ step 2 picks the local backend. | |
| * โข Frontend deployed to a cloud host โ step 3 picks the live Render backend. | |
| */ | |
| function resolveApiBase(): string { | |
| const fromEnv = (import.meta as any).env?.VITE_RASAD_API_URL as string | undefined; | |
| if (fromEnv && fromEnv.trim().length > 0) return fromEnv; | |
| if (typeof window !== "undefined") { | |
| const host = window.location.hostname; | |
| if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) { | |
| return "http://localhost:8000/api/v1"; | |
| } | |
| } | |
| // Production fallback โ Render-deployed backend (Frankfurt, free tier) | |
| return "https://rasad-backend-0fa5.onrender.com/api/v1"; | |
| } | |
| export const RASAD_API_BASE: string = resolveApiBase(); | |
| export type AgentMode = "real" | "demo" | "fallback" | "mixed"; | |
| export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient_data" | "timeout"; | |
| export type VerdictLabel = | |
| | "VERIFIED" | |
| | "FALSE" | |
| | "MISLEADING" | |
| | "AI_GENERATED" | |
| | "MANIPULATED" | |
| | "OLD_NEWS" | |
| | "UNVERIFIED" | |
| | "DUPLICATE" | |
| | "HIGH_RISK"; | |
| export interface AgentResult { | |
| agent: string; | |
| score: number; | |
| confidence: ConfidenceLevel; | |
| evidence: string[]; | |
| raw: Record<string, unknown>; | |
| elapsed_ms: number; | |
| mode: AgentMode; | |
| } | |
| export interface FinalVerdict { | |
| id: string; | |
| verdict: VerdictLabel; | |
| confidence: number; | |
| arabic_explanation: string; | |
| english_explanation?: string; | |
| agent_breakdown: Record<string, AgentResult>; | |
| sources: string[]; | |
| processing_time_ms: number; | |
| content_type: string; | |
| content_hash?: string; | |
| weighted_score?: number; | |
| mode: AgentMode; | |
| cached?: boolean; | |
| created_at: string; | |
| } | |
| export interface WebSourceMatch { | |
| title: string; | |
| summary: string; | |
| url: string; | |
| source_name: string; | |
| domain: string; | |
| pub_date?: string | null; | |
| kind: "rss" | "web" | string; | |
| engine?: string; | |
| similarity: number; | |
| } | |
| export interface WebSearchHit { | |
| title: string; | |
| url: string; | |
| snippet: string; | |
| source_domain: string; | |
| language: string; | |
| pub_date?: string | null; | |
| engine: string; | |
| score: number; | |
| scraped?: { | |
| title: string; | |
| summary: string; | |
| word_count: number; | |
| language: string; | |
| method: string; | |
| pub_date?: string | null; | |
| }; | |
| } | |
| export interface SearchResponse { | |
| query: string; | |
| language: string; | |
| comprehensive: boolean; | |
| scraped: boolean; | |
| engines: Record<string, boolean>; | |
| count: number; | |
| results: WebSearchHit[]; | |
| } | |
| export interface LiveItem { | |
| id: string; | |
| title: string; | |
| summary: string; | |
| url: string; | |
| source_name: string; | |
| source_domain: string; | |
| pub_date: string | null; | |
| seen_at: string; | |
| emotion_score: number; | |
| fake_score: number; | |
| risk_label: "verified" | "suspicious" | "risky" | "neutral" | string; | |
| flags: string[]; | |
| topic: string; | |
| } | |
| export interface LiveFeedResponse { | |
| items: LiveItem[]; | |
| watermark: string | null; | |
| monitor: "live" | "disabled" | string; | |
| } | |
| export interface LiveStatsResponse { | |
| total: number; | |
| max: number; | |
| by_risk: Record<string, number>; | |
| by_source: Record<string, number>; | |
| cycles_completed: number; | |
| last_run_at: string | null; | |
| last_added_count: number; | |
| interval_seconds: number; | |
| errors_total: number; | |
| monitor: "live" | "disabled" | string; | |
| } | |
| export interface MonitorItem { | |
| id: string; | |
| title: string; | |
| summary: string; | |
| url: string; | |
| source_name: string; | |
| verdict?: VerdictLabel; | |
| confidence: number; | |
| published_at: string; | |
| flagged: boolean; | |
| } | |
| export interface RasadHealth { | |
| status: string; | |
| agents: number; | |
| version: string; | |
| services: Record<string, string>; | |
| environment: string; | |
| } | |
| export class RasadApiError extends Error { | |
| status: number; | |
| detail?: string; | |
| constructor(message: string, status: number, detail?: string) { | |
| super(message); | |
| this.name = "RasadApiError"; | |
| this.status = status; | |
| this.detail = detail; | |
| } | |
| } | |
| async function request<T>(path: string, init: RequestInit = {}): Promise<T> { | |
| const url = `${RASAD_API_BASE}${path}`; | |
| const headers = new Headers(init.headers); | |
| if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) { | |
| headers.set("Content-Type", "application/json"); | |
| } | |
| let resp: Response; | |
| try { | |
| resp = await fetch(url, { ...init, headers }); | |
| } catch (e) { | |
| // Network-level failure: backend unreachable, CORS, DNS, โฆ | |
| const isLocalhost = /(?:localhost|127\.0\.0\.1)/.test(RASAD_API_BASE); | |
| const hint = isLocalhost | |
| ? "ุฎุงุฏู ุงูุชุญูู ุบูุฑ ุดุบูุงู ุนูู ูุฐุง ุงูุฌูุงุฒ. ุดุบูู ุงูู backend (uvicorn) ุฃู ุญุฏูุซ VITE_RASAD_API_URL ูููุดูุฑ ูุฎุงุฏู ุจุนูุฏ." | |
| : `ุชุนุฐูุฑ ุงูุงุชุตุงู ุจู ${RASAD_API_BASE}. ุชุฃููุฏ ุฃู ุงูู backend ู ูุดูุฑ ูุฃู CORS ูุณู ุญ ููุฐุง ุงูุฏูู ูู.`; | |
| throw new RasadApiError(hint, 0, e instanceof Error ? e.message : String(e)); | |
| } | |
| if (!resp.ok) { | |
| let detail: string | undefined; | |
| try { | |
| const body = await resp.json(); | |
| detail = body?.detail || body?.message || JSON.stringify(body).slice(0, 200); | |
| } catch { | |
| detail = await resp.text().catch(() => undefined); | |
| } | |
| // 404 on the verify endpoints almost always means the backend isn't deployed | |
| // (e.g. frontend on a static host pointing at localhost or a wrong URL). | |
| if (resp.status === 404 && /\/verify\//.test(path)) { | |
| throw new RasadApiError( | |
| `ุงูู API endpoint ุบูุฑ ู ูุฌูุฏ (${path}). ุชุญููู ู ู VITE_RASAD_API_URL โ ูุฏ ุชููู ููู ุชู ู ููููุฃุฉ ูู localhost ุนูู Hostingerุ ุฃู ุงูู backend ุบูุฑ ู ูุดุบูู.`, | |
| 404, | |
| detail, | |
| ); | |
| } | |
| throw new RasadApiError( | |
| `RASAD API ${resp.status}: ${resp.statusText}`, | |
| resp.status, | |
| detail, | |
| ); | |
| } | |
| return (await resp.json()) as T; | |
| } | |
| export async function verifyText( | |
| text: string, | |
| options: { language?: "ar" | "en" | "auto"; signal?: AbortSignal } = {}, | |
| ): Promise<FinalVerdict> { | |
| return request<FinalVerdict>("/verify/text", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| text, | |
| language: options.language ?? "auto", | |
| include_breakdown: true, | |
| }), | |
| signal: options.signal, | |
| }); | |
| } | |
| export async function verifyUrl( | |
| url: string, | |
| options: { signal?: AbortSignal } = {}, | |
| ): Promise<FinalVerdict> { | |
| return request<FinalVerdict>("/verify/url", { | |
| method: "POST", | |
| body: JSON.stringify({ url, language: "auto", include_breakdown: true }), | |
| signal: options.signal, | |
| }); | |
| } | |
| export async function verifyMedia( | |
| file: File, | |
| options: { contextText?: string; mediaType?: "image" | "video" | "audio"; signal?: AbortSignal } = {}, | |
| ): Promise<FinalVerdict> { | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| fd.append("media_type", options.mediaType ?? "image"); | |
| if (options.contextText) fd.append("context_text", options.contextText); | |
| return request<FinalVerdict>("/verify/media", { | |
| method: "POST", | |
| body: fd, | |
| signal: options.signal, | |
| }); | |
| } | |
| export async function getLiveFeed( | |
| options: { | |
| limit?: number; | |
| since?: string | null; | |
| risk?: string; | |
| domain?: string; | |
| topic?: string; | |
| signal?: AbortSignal; | |
| } = {}, | |
| ): Promise<LiveFeedResponse> { | |
| const params = new URLSearchParams({ limit: String(options.limit ?? 30) }); | |
| if (options.since) params.set("since", options.since); | |
| if (options.risk) params.set("risk", options.risk); | |
| if (options.domain) params.set("domain", options.domain); | |
| if (options.topic) params.set("topic", options.topic); | |
| return request<LiveFeedResponse>(`/live/feed?${params.toString()}`, { signal: options.signal }); | |
| } | |
| export async function getLiveStats(): Promise<LiveStatsResponse> { | |
| return request<LiveStatsResponse>("/live/stats"); | |
| } | |
| export async function refreshLive(): Promise<{ ok: boolean; kicked_at: string }> { | |
| return request<{ ok: boolean; kicked_at: string }>("/live/refresh", { method: "POST" }); | |
| } | |
| export async function searchWeb( | |
| query: string, | |
| options: { limit?: number; language?: "ar" | "en"; comprehensive?: boolean; scrape?: boolean } = {}, | |
| ): Promise<SearchResponse> { | |
| const params = new URLSearchParams({ | |
| q: query, | |
| limit: String(options.limit ?? 10), | |
| language: options.language ?? "ar", | |
| comprehensive: String(options.comprehensive ?? false), | |
| scrape: String(options.scrape ?? false), | |
| }); | |
| return request<SearchResponse>(`/search?${params.toString()}`); | |
| } | |
| export async function getMonitorFeed(limit = 20): Promise<{ items: MonitorItem[]; sources: string[] }> { | |
| return request<{ items: MonitorItem[]; sources: string[] }>( | |
| `/monitor/feed?limit=${limit}`, | |
| ); | |
| } | |
| export async function getTrending(limit = 10): Promise<{ items: any[] }> { | |
| return request<{ items: any[] }>(`/monitor/trending?limit=${limit}`); | |
| } | |
| export async function getHealth(): Promise<RasadHealth> { | |
| return request<RasadHealth>("/health"); | |
| } | |
| export async function pingRasad(): Promise<boolean> { | |
| try { | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), 1500); | |
| const resp = await fetch(`${RASAD_API_BASE}/health`, { signal: ctrl.signal }); | |
| clearTimeout(t); | |
| return resp.ok; | |
| } catch { | |
| return false; | |
| } | |
| } | |