const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api"; const UPLOAD_TIMEOUT = parseInt(import.meta.env.VITE_UPLOAD_TIMEOUT || "600000", 10); let authToken = null; let tokenRefreshTimer = null; export function setToken(token) { authToken = token; localStorage.setItem("authToken", token); scheduleTokenRefresh(); } export function getToken() { if (!authToken) { authToken = localStorage.getItem("authToken"); } return authToken; } export function clearToken() { authToken = null; localStorage.removeItem("authToken"); if (tokenRefreshTimer) { clearTimeout(tokenRefreshTimer); } } function scheduleTokenRefresh() { // Refresh token 5 minutes before expiry (assuming 24h token) const refreshIn = 23 * 60 * 60 * 1000; // 23 hours if (tokenRefreshTimer) { clearTimeout(tokenRefreshTimer); } tokenRefreshTimer = setTimeout(async () => { try { // Re-login would require stored credentials // For now, just notify user to re-login console.warn("Token expiring soon, please re-login"); } catch (error) { console.error("Token refresh failed:", error); } }, refreshIn); } async function request(endpoint, options = {}) { const url = `${API_BASE_URL}${endpoint}`; const headers = { "Content-Type": "application/json", ...options.headers, }; const token = getToken(); if (token) { headers["Authorization"] = `Bearer ${token}`; } const response = await fetch(url, { ...options, headers, }); if (response.status === 401) { const error = await response.json(); if (error.detail?.error === "token_expired") { clearToken(); window.electronAPI.navigate("login"); throw new Error("Session expired, please login again"); } throw new Error(error.detail || "Unauthorized"); } if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.detail || `HTTP error ${response.status}`); } // Handle blob responses for export if (options.responseType === "blob") { return response.blob(); } return response.json(); } // Auth API export async function login(email, password) { const data = await request("/login", { method: "POST", body: JSON.stringify({ email, password }), }); setToken(data.token); localStorage.setItem("userEmail", data.email); localStorage.setItem("userRole", data.role); return data; } export async function getMe() { return request("/me"); } // Meetings API export async function getMeetings() { return request("/meetings"); } export async function getMeeting(id) { return request(`/meetings/${id}`); } export async function createMeeting(meeting) { return request("/meetings", { method: "POST", body: JSON.stringify(meeting), }); } export async function updateMeeting(id, meeting) { return request(`/meetings/${id}`, { method: "PUT", body: JSON.stringify(meeting), }); } export async function deleteMeeting(id) { return request(`/meetings/${id}`, { method: "DELETE", }); } export async function updateActionItem(meetingId, actionId, data) { return request(`/meetings/${meetingId}/actions/${actionId}`, { method: "PUT", body: JSON.stringify(data), }); } // AI API export async function summarizeTranscript(transcript) { return request("/ai/summarize", { method: "POST", body: JSON.stringify({ transcript }), }); } export async function transcribeAudio(file, onProgress = null) { const url = `${API_BASE_URL}/ai/transcribe-audio-stream`; const formData = new FormData(); formData.append("file", file); const token = getToken(); return new Promise((resolve, reject) => { // Use fetch for SSE support fetch(url, { method: "POST", headers: { Authorization: token ? `Bearer ${token}` : undefined, }, body: formData, }) .then((response) => { if (response.status === 401) { clearToken(); window.electronAPI?.navigate("login"); throw new Error("Session expired, please login again"); } if (!response.ok) { return response.json().then((error) => { throw new Error(error.detail || `HTTP error ${response.status}`); }); } if (onProgress) { onProgress({ phase: "processing", progress: 0, message: "處理中..." }); } // Read SSE stream const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let result = null; let totalChunks = 0; let processedChunks = 0; function processLine(line) { if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); switch (data.event) { case "start": case "segmenting": if (onProgress) { onProgress({ phase: "processing", progress: 5, message: data.message, }); } break; case "segments_ready": totalChunks = data.total; if (onProgress) { onProgress({ phase: "transcribing", progress: 10, total: totalChunks, current: 0, message: data.message, }); } break; case "chunk_start": if (onProgress) { const progress = 10 + ((data.chunk - 1) / totalChunks) * 85; onProgress({ phase: "transcribing", progress: Math.round(progress), total: totalChunks, current: data.chunk, message: data.message, }); } break; case "chunk_done": processedChunks++; if (onProgress) { const progress = 10 + (data.chunk / totalChunks) * 85; onProgress({ phase: "transcribing", progress: Math.round(progress), total: totalChunks, current: data.chunk, message: data.message, }); } break; case "chunk_error": console.warn(`Chunk ${data.chunk} error: ${data.message}`); break; case "error": throw new Error(data.message); case "complete": result = { transcript: data.transcript, chunks_processed: data.chunks_processed, chunks_total: data.chunks_total, total_duration_seconds: data.duration, language: "zh", }; if (onProgress) { onProgress({ phase: "complete", progress: 100, message: "轉錄完成", }); } break; } } catch (e) { console.warn("SSE parse error:", e, line); } } } function read() { reader .read() .then(({ done, value }) => { if (done) { // Process any remaining buffer if (buffer.trim()) { buffer.split("\n").forEach(processLine); } if (result) { resolve(result); } else { reject(new Error("Transcription failed - no result received")); } return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; // Keep incomplete line in buffer lines.forEach(processLine); read(); }) .catch(reject); } read(); }) .catch(reject); }); } // Legacy non-streaming version (fallback) export async function transcribeAudioLegacy(file, onProgress = null) { const url = `${API_BASE_URL}/ai/transcribe-audio`; const formData = new FormData(); formData.append("file", file); const token = getToken(); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable && onProgress) { const percentComplete = Math.round((event.loaded / event.total) * 100); onProgress({ phase: "uploading", progress: percentComplete }); } }); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) { try { const response = JSON.parse(xhr.responseText); resolve(response); } catch (e) { reject(new Error("Invalid response format")); } } else if (xhr.status === 401) { clearToken(); window.electronAPI?.navigate("login"); reject(new Error("Session expired, please login again")); } else { try { const error = JSON.parse(xhr.responseText); reject(new Error(error.detail || `HTTP error ${xhr.status}`)); } catch (e) { reject(new Error(`HTTP error ${xhr.status}`)); } } }); xhr.addEventListener("error", () => { reject(new Error("Network error")); }); xhr.addEventListener("timeout", () => { reject(new Error("Request timeout")); }); xhr.open("POST", url, true); xhr.timeout = UPLOAD_TIMEOUT; if (token) { xhr.setRequestHeader("Authorization", `Bearer ${token}`); } xhr.send(formData); // Notify processing phase after upload completes if (onProgress) { xhr.upload.addEventListener("loadend", () => { onProgress({ phase: "processing", progress: 0 }); }); } }); } // Export API export async function exportMeeting(id) { return request(`/meetings/${id}/export`, { responseType: "blob", }); }