| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const API_BASE = (import.meta.env.VITE_API_BASE || "http://127.0.0.1:8000/api").replace(/\/$/, ""); |
|
|
| export const AUDIO_INPUT_HINT = |
| import.meta.env.VITE_AUDIO_INPUT_HINT || |
| "Audio mode uses Whisper for speech-to-text. Speak clearly for best results."; |
|
|
| |
| const REQUEST_TIMEOUT = 30000; |
| const MAX_RETRIES = 2; |
| const RETRY_DELAY = 1000; |
| const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504]; |
|
|
| |
| |
| |
| function createTimeoutController(timeoutMs = REQUEST_TIMEOUT) { |
| const controller = new AbortController(); |
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs); |
| return { controller, timeoutId }; |
| } |
|
|
| |
| |
| |
| function isRetryableError(error, status) { |
| |
| if (error.name === 'TypeError' || error.name === 'AbortError') { |
| return true; |
| } |
| |
| if (status && RETRYABLE_STATUSES.includes(status)) { |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| function sleep(ms) { |
| return new Promise(resolve => setTimeout(resolve, ms)); |
| } |
|
|
| async function reqWithRetry(path, options = {}, attempt = 0) { |
| const token = localStorage.getItem("firebaseToken"); |
| const headers = { |
| "Content-Type": "application/json", |
| ...(token ? { "Authorization": `Bearer ${token}` } : {}), |
| ...(options.headers || {}) |
| }; |
|
|
| const { controller, timeoutId } = createTimeoutController(options.timeout); |
| |
| try { |
| const res = await fetch(`${API_BASE}${path}`, { |
| ...options, |
| headers, |
| signal: controller.signal, |
| }); |
| |
| clearTimeout(timeoutId); |
| |
| if (!res.ok) { |
| const data = await res.json().catch(() => ({})); |
| const error = new Error(data.detail || data.message || `API error ${res.status}`); |
| error.status = res.status; |
| error.data = data; |
| |
| |
| if (attempt < MAX_RETRIES && isRetryableError(error, res.status)) { |
| console.warn(`API request failed (attempt ${attempt + 1}), retrying...`, error.message); |
| await sleep(RETRY_DELAY * (attempt + 1)); |
| return reqWithRetry(path, options, attempt + 1); |
| } |
| |
| throw error; |
| } |
| |
| return res.json(); |
| } catch (error) { |
| clearTimeout(timeoutId); |
| |
| |
| if (error.name === 'AbortError') { |
| const timeoutError = new Error('Request timed out. Please check your connection and try again.'); |
| timeoutError.status = 408; |
| timeoutError.isTimeout = true; |
| |
| if (attempt < MAX_RETRIES) { |
| console.warn(`Request timeout (attempt ${attempt + 1}), retrying...`); |
| await sleep(RETRY_DELAY * (attempt + 1)); |
| return reqWithRetry(path, options, attempt + 1); |
| } |
| |
| throw timeoutError; |
| } |
| |
| |
| if (attempt < MAX_RETRIES && isRetryableError(error)) { |
| console.warn(`Network error (attempt ${attempt + 1}), retrying...`, error.message); |
| await sleep(RETRY_DELAY * (attempt + 1)); |
| return reqWithRetry(path, options, attempt + 1); |
| } |
| |
| |
| if (error.name === 'TypeError' && error.message.includes('fetch')) { |
| error.message = 'Network error. Please check your internet connection and try again.'; |
| error.isNetworkError = true; |
| } |
| |
| throw error; |
| } |
| } |
|
|
| async function reqMultipartWithRetry(url, body, attempt = 0) { |
| const token = localStorage.getItem("firebaseToken"); |
| const headers = { |
| ...(token ? { "Authorization": `Bearer ${token}` } : {}) |
| }; |
|
|
| const { controller, timeoutId } = createTimeoutController(60000); |
|
|
| try { |
| const res = await fetch(url, { |
| method: "POST", |
| body, |
| headers, |
| signal: controller.signal, |
| }); |
| |
| clearTimeout(timeoutId); |
| |
| if (!res.ok) { |
| const data = await res.json().catch(() => ({})); |
| const error = new Error(data.detail || data.message || `API error ${res.status}`); |
| error.status = res.status; |
| error.data = data; |
| |
| |
| if (attempt < MAX_RETRIES && isRetryableError(error, res.status) && res.status !== 413) { |
| console.warn(`Multipart request failed (attempt ${attempt + 1}), retrying...`, error.message); |
| await sleep(RETRY_DELAY * (attempt + 1)); |
| return reqMultipartWithRetry(url, body, attempt + 1); |
| } |
| |
| throw error; |
| } |
| |
| return res.json(); |
| } catch (error) { |
| clearTimeout(timeoutId); |
| |
| if (error.name === 'AbortError') { |
| const timeoutError = new Error('Upload timed out. The file may be too large or your connection is slow.'); |
| timeoutError.status = 408; |
| timeoutError.isTimeout = true; |
| throw timeoutError; |
| } |
| |
| if (attempt < MAX_RETRIES && isRetryableError(error)) { |
| console.warn(`Multipart network error (attempt ${attempt + 1}), retrying...`, error.message); |
| await sleep(RETRY_DELAY * (attempt + 1)); |
| return reqMultipartWithRetry(url, body, attempt + 1); |
| } |
| |
| if (error.name === 'TypeError' && error.message.includes('fetch')) { |
| error.message = 'Network error during upload. Please check your connection and try again.'; |
| error.isNetworkError = true; |
| } |
| |
| throw error; |
| } |
| } |
|
|
| |
| async function req(path, options = {}) { |
| return reqWithRetry(path, options); |
| } |
|
|
| async function reqMultipart(url, body) { |
| return reqMultipartWithRetry(url, body); |
| } |
|
|
| |
| |
| |
| export function isNetworkError(error) { |
| return error?.isNetworkError === true || error?.isTimeout === true || |
| error?.name === 'TypeError' || error?.name === 'AbortError'; |
| } |
|
|
| |
| |
| |
| export function isRetryable(error) { |
| return isNetworkError(error) || RETRYABLE_STATUSES.includes(error?.status); |
| } |
|
|
| |
| function audioExtFromMime(mimeType) { |
| if (!mimeType) return ".webm"; |
| if (mimeType.includes("mp4")) return ".mp4"; |
| if (mimeType.includes("ogg")) return ".ogg"; |
| if (mimeType.includes("wav")) return ".wav"; |
| return ".webm"; |
| } |
|
|
| export const api = { |
| getHealth: () => req("/health"), |
| createSession: () => req("/session/create", { method: "POST" }), |
|
|
| uploadResume: (sid, file) => { |
| const fd = new FormData(); |
| fd.append("session_id", sid); |
| fd.append("file", file); |
| return reqMultipart(`${API_BASE}/upload/resume`, fd); |
| }, |
|
|
| parseResume: (sid) => req(`/parse/resume/${sid}`, { method: "POST" }), |
| |
| |
| parseAndExtract: (sid, file) => { |
| const fd = new FormData(); |
| fd.append("session_id", sid); |
| fd.append("resume", file); |
| return reqMultipart(`${API_BASE}/parse-and-extract`, fd); |
| }, |
| |
| |
| generateDynamicInterview: (sid) => req(`/interview/generate-dynamic/${sid}`, { method: "POST" }), |
| |
| setJobDescription: (payload) => req("/session/job_description", { method: "POST", body: JSON.stringify(payload) }), |
| setCandidateProfile: (payload) => req("/session/candidate_profile", { method: "POST", body: JSON.stringify(payload) }), |
| generatePlan: (sid) => req(`/interview/plan/${sid}`, { method: "POST" }), |
| startInterview: (sid) => req(`/session/start_interview?session_id=${encodeURIComponent(sid)}`, { method: "POST" }), |
| nextQuestion: (sid) => req(`/session/next_question?session_id=${encodeURIComponent(sid)}`, { method: "POST" }), |
| scoreText: (payload) => req("/score/text", { method: "POST", body: JSON.stringify(payload) }), |
|
|
| scoreAudio: (sid, qid, blob) => { |
| |
| const ext = audioExtFromMime(blob.type); |
| const filename = `answer${ext}`; |
| const fd = new FormData(); |
| fd.append("file", blob, filename); |
| return reqMultipart( |
| `${API_BASE}/answer/audio?session_id=${encodeURIComponent(sid)}&question_id=${encodeURIComponent(qid)}`, |
| fd |
| ); |
| }, |
|
|
| sendPosture: (payload) => req("/posture/report", { method: "POST", body: JSON.stringify(payload) }), |
| logViolation: (payload) => req("/session/violation", { method: "POST", body: JSON.stringify(payload) }), |
| aggregate: (sid) => req(`/aggregate/${sid}`, { method: "POST" }), |
| analytics: (sid) => req(`/analytics/${sid}`, { method: "POST" }), |
| decision: (sid) => req(`/decision/${sid}`, { method: "POST" }), |
| getReport: (sid) => req(`/report/${sid}`), |
| getUserHistory: () => req("/user/history"), |
| |
| |
| getSessionStatus: (sid) => req(`/session/status/${encodeURIComponent(sid)}`), |
| skipQuestion: (sid, qid) => req(`/session/skip/${encodeURIComponent(sid)}?question_id=${encodeURIComponent(qid || "")}`, { method: "POST" }), |
| }; |
|
|