- Add environment variable configuration for backend and frontend - Backend: DB_POOL_SIZE, JWT_EXPIRE_HOURS, timeout configs, directory paths - Frontend: VITE_API_BASE_URL, VITE_UPLOAD_TIMEOUT, Whisper configs - Create deployment script (scripts/deploy-backend.sh) - Create 1Panel deployment guide (docs/1panel-deployment.md) - Update DEPLOYMENT.md with env var documentation - Create README.md with project overview - Remove obsolete PRD.md, SDD.md, TDD.md (replaced by OpenSpec) - Keep CORS allow_origins=["*"] for Electron EXE distribution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
376 lines
10 KiB
JavaScript
376 lines
10 KiB
JavaScript
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",
|
|
});
|
|
}
|