From 19dc7bc06fbc41daa6d90937edc8c22661fede50 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:22:13 +0530 Subject: [PATCH 1/5] feat(*): phase-2 refactoring --- app/api/evaluations/stt/files/route.ts | 53 +- .../tts/datasets/[dataset_id]/route.ts | 98 +- app/api/evaluations/tts/datasets/route.ts | 56 +- .../tts/results/[result_id]/route.ts | 57 +- .../evaluations/tts/runs/[run_id]/route.ts | 30 +- app/api/evaluations/tts/runs/route.ts | 55 +- app/api/languages/route.ts | 18 +- app/lib/apiClient.ts | 4 +- app/lib/context/AuthContext.tsx | 102 +- app/lib/useConfigs.ts | 329 +-- app/speech-to-text/page.tsx | 2445 ++++++++++++----- app/text-to-speech/page.tsx | 1911 +++++++++---- 12 files changed, 3453 insertions(+), 1705 deletions(-) diff --git a/app/api/evaluations/stt/files/route.ts b/app/api/evaluations/stt/files/route.ts index 143dbaf1..0d898283 100644 --- a/app/api/evaluations/stt/files/route.ts +++ b/app/api/evaluations/stt/files/route.ts @@ -1,49 +1,28 @@ -import { NextRequest, NextResponse } from 'next/server'; - +import { apiClient } from "@/app/lib/apiClient"; +import { NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { try { - // Get the API key from request headers - const apiKey = request.headers.get('X-API-KEY'); - - if (!apiKey) { - return NextResponse.json( - { error: 'Missing X-API-KEY header' }, - { status: 401 } - ); - } - - // Get the form data from the request const formData = await request.formData(); - // Get backend URL from environment variable - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - - // Forward the request to the actual backend - const response = await fetch(`${backendUrl}/api/v1/evaluations/stt/files`, { - method: 'POST', - body: formData, - headers: { - 'X-API-KEY': apiKey, - + const { status, data: responseData } = await apiClient( + request, + "/api/v1/evaluations/stt/files", + { + method: "POST", + body: formData, }, - }); - - // Handle empty responses (204 No Content, etc.) - const text = await response.text(); - const data = text ? JSON.parse(text) : { success: true }; - - // Return the response with the same status code - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } + ); - return NextResponse.json(data, { status: response.status }); + return NextResponse.json(responseData, { status }); } catch (error: unknown) { - console.error('Proxy error:', error); + console.error("Proxy error:", error); return NextResponse.json( - { error: 'Failed to forward request to backend', details: error instanceof Error ? error.message : String(error) }, - { status: 500 } + { + error: "Failed to forward request to backend", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, ); } } diff --git a/app/api/evaluations/tts/datasets/[dataset_id]/route.ts b/app/api/evaluations/tts/datasets/[dataset_id]/route.ts index 49b84d60..f9bba0c1 100644 --- a/app/api/evaluations/tts/datasets/[dataset_id]/route.ts +++ b/app/api/evaluations/tts/datasets/[dataset_id]/route.ts @@ -1,19 +1,11 @@ -import { NextResponse } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse } from "next/server"; export async function GET( request: Request, - { params }: { params: Promise<{ dataset_id: string }> } + { params }: { params: Promise<{ dataset_id: string }> }, ) { const { dataset_id } = await params; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - - if (!apiKey) { - return NextResponse.json( - { success: false, error: 'Unauthorized: Missing API key', data: null }, - { status: 401 } - ); - } try { // Forward query parameters to the backend @@ -22,64 +14,88 @@ export async function GET( for (const [key, value] of searchParams.entries()) { backendParams.append(key, value); } - const queryString = backendParams.toString() ? `?${backendParams.toString()}` : ''; + const queryString = backendParams.toString() + ? `?${backendParams.toString()}` + : ""; - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/datasets/${dataset_id}${queryString}`, { - headers: { - 'X-API-KEY': apiKey, - }, - }); - - const data = await response.json(); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } + const { data, status } = await apiClient( + request, + `/api/v1/evaluations/tts/datasets/${dataset_id}${queryString}`, + ); // If fetch_content=true, download the CSV from the signed URL and return it - const fetchContent = new URL(request.url).searchParams.get('fetch_content'); - if (fetchContent === 'true') { + const fetchContent = new URL(request.url).searchParams.get("fetch_content"); + if (fetchContent === "true") { const signedUrl = data?.data?.signed_url || data?.signed_url; if (!signedUrl) { - return NextResponse.json({ error: 'No signed URL available' }, { status: 404 }); + return NextResponse.json( + { error: "No signed URL available" }, + { status: 404 }, + ); } const csvResponse = await fetch(signedUrl); if (!csvResponse.ok) { - return NextResponse.json({ error: 'Failed to fetch CSV file' }, { status: 502 }); + return NextResponse.json( + { error: "Failed to fetch CSV file" }, + { status: 502 }, + ); } const csvText = await csvResponse.text(); - return NextResponse.json({ ...data, csv_content: csvText }, { status: 200 }); + return NextResponse.json( + { ...data, csv_content: csvText }, + { status: 200 }, + ); } - return NextResponse.json(data, { status: response.status }); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( - { success: false, error: 'Failed to fetch dataset', data: null }, - { status: 500 } + { success: false, error: "Failed to fetch dataset", data: null }, + { status: 500 }, ); } } export async function DELETE( request: Request, - { params }: { params: Promise<{ dataset_id: string }> } + { params }: { params: Promise<{ dataset_id: string }> }, ) { const { dataset_id } = await params; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); + const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const apiKey = request.headers.get("X-API-KEY"); if (!apiKey) { - return NextResponse.json({ success: false, error: 'Unauthorized: Missing API key' }, { status: 401 }); + return NextResponse.json( + { success: false, error: "Unauthorized: Missing API key" }, + { status: 401 }, + ); } try { - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/datasets/${dataset_id}`, { - method: 'DELETE', - headers: { 'X-API-KEY': apiKey }, - }); + const response = await fetch( + `${backendUrl}/api/v1/evaluations/tts/datasets/${dataset_id}`, + { + method: "DELETE", + headers: { "X-API-KEY": apiKey }, + }, + ); let data; - try { data = await response.json(); } catch { data = { success: true }; } - return NextResponse.json(data, { status: response.ok ? 200 : response.status }); + try { + data = await response.json(); + } catch { + data = { success: true }; + } + return NextResponse.json(data, { + status: response.ok ? 200 : response.status, + }); } catch (error: unknown) { - return NextResponse.json({ success: false, error: 'Failed to delete dataset', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return NextResponse.json( + { + success: false, + error: "Failed to delete dataset", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); } } diff --git a/app/api/evaluations/tts/datasets/route.ts b/app/api/evaluations/tts/datasets/route.ts index a7a5da3f..e3b571b3 100644 --- a/app/api/evaluations/tts/datasets/route.ts +++ b/app/api/evaluations/tts/datasets/route.ts @@ -1,53 +1,41 @@ -import { NextResponse, NextRequest } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; export async function GET(request: Request) { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - try { - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/datasets`, { - headers: { - 'X-API-KEY': apiKey || '', - }, - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient( + request, + "/api/v1/evaluations/tts/datasets", + ); + return NextResponse.json(data, { status }); } catch (error) { return NextResponse.json( { success: false, error: error, data: null }, - { status: 500 } + { status: 500 }, ); } } export async function POST(request: NextRequest) { try { - const apiKey = request.headers.get('X-API-KEY'); - if (!apiKey) { - return NextResponse.json( - { error: 'Missing X-API-KEY. Either generate an API Key. Contact Kaapi team for more details' }, - { status: 401 } - ); - } const body = await request.json(); - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/datasets`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'X-API-KEY': apiKey, - 'Content-Type': 'application/json', + const { status, data } = await apiClient( + request, + "/api/v1/evaluations/tts/datasets", + { + method: "POST", + body: JSON.stringify(body), }, - }); - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + ); + return NextResponse.json(data, { status }); } catch (error) { - console.error('Proxy error:', error); + console.error("Proxy error:", error); return NextResponse.json( - { error: 'Failed to forward request to backend', details: error instanceof Error ? error.message : String(error) }, - { status: 500 } + { + error: "Failed to forward request to backend", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, ); } } diff --git a/app/api/evaluations/tts/results/[result_id]/route.ts b/app/api/evaluations/tts/results/[result_id]/route.ts index 14489d58..cb867981 100644 --- a/app/api/evaluations/tts/results/[result_id]/route.ts +++ b/app/api/evaluations/tts/results/[result_id]/route.ts @@ -1,63 +1,44 @@ -import { NextResponse } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse } from "next/server"; export async function GET( request: Request, - { params }: { params: Promise<{ result_id: string }> } + { params }: { params: Promise<{ result_id: string }> }, ) { const { result_id } = await params; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); try { - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/results/${result_id}`, { - headers: { - 'X-API-KEY': apiKey || '', - }, - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient( + request, + `/api/v1/evaluations/tts/results/${result_id}`, + ); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( - { success: false, error: 'Failed to fetch results', data: null }, - { status: 500 } + { success: false, error: "Failed to fetch results", data: null }, + { status: 500 }, ); } } export async function PATCH( request: Request, - { params }: { params: Promise<{ result_id: string }> } + { params }: { params: Promise<{ result_id: string }> }, ) { const { result_id } = await params; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - - if (!apiKey) { - return NextResponse.json( - { error: 'Missing X-API-KEY header' }, - { status: 401 } - ); - } try { const body = await request.json(); - - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/results/${result_id}`, { - method: 'PATCH', - headers: { - 'X-API-KEY': apiKey || '', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient( + request, + `/api/v1/evaluations/tts/results/${result_id}`, + { method: "PATCH", body: JSON.stringify(body) }, + ); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( - { success: false, error: 'Failed to update result feedback', data: null }, - { status: 500 } + { success: false, error: "Failed to update result feedback", data: null }, + { status: 500 }, ); } } diff --git a/app/api/evaluations/tts/runs/[run_id]/route.ts b/app/api/evaluations/tts/runs/[run_id]/route.ts index fe5e2650..182f26a0 100644 --- a/app/api/evaluations/tts/runs/[run_id]/route.ts +++ b/app/api/evaluations/tts/runs/[run_id]/route.ts @@ -1,33 +1,25 @@ -import { NextResponse } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse } from "next/server"; export async function GET( request: Request, - { params }: { params: Promise<{ run_id: string }> } + { params }: { params: Promise<{ run_id: string }> }, ) { const { run_id } = await params; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - const { searchParams } = new URL(request.url); const queryString = searchParams.toString(); - try { - const backendUrlWithParams = queryString - ? `${backendUrl}/api/v1/evaluations/tts/runs/${run_id}?${queryString}` - : `${backendUrl}/api/v1/evaluations/tts/runs/${run_id}`; - - const response = await fetch(backendUrlWithParams, { - headers: { - 'X-API-KEY': apiKey || '', - }, - }); + const endpoint = queryString + ? `/api/v1/evaluations/tts/runs/${run_id}?${queryString}` + : `/api/v1/evaluations/tts/runs/${run_id}`; - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + try { + const { status, data } = await apiClient(request, endpoint); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( - { success: false, error: 'Failed to fetch the run', data: null }, - { status: 500 } + { success: false, error: "Failed to fetch the run", data: null }, + { status: 500 }, ); } } diff --git a/app/api/evaluations/tts/runs/route.ts b/app/api/evaluations/tts/runs/route.ts index 37d47199..c838db38 100644 --- a/app/api/evaluations/tts/runs/route.ts +++ b/app/api/evaluations/tts/runs/route.ts @@ -1,53 +1,38 @@ -import { NextResponse, NextRequest } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextRequest, NextResponse } from "next/server"; export async function GET(request: Request) { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - try { - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/runs`, { - headers: { - 'X-API-KEY': apiKey || '', - }, - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient( + request, + "/api/v1/evaluations/tts/runs", + ); + return NextResponse.json(data, { status }); } catch (error) { return NextResponse.json( { success: false, error: error, data: null }, - { status: 500 } + { status: 500 }, ); } } export async function POST(request: NextRequest) { try { - const apiKey = request.headers.get('X-API-KEY'); - if (!apiKey) { - return NextResponse.json( - { error: 'Missing X-API-KEY. Either generate an API Key. Contact Kaapi team for more details' }, - { status: 401 } - ); - } const body = await request.json(); - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - - const response = await fetch(`${backendUrl}/api/v1/evaluations/tts/runs`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'X-API-KEY': apiKey, - 'Content-Type': 'application/json', - }, - }); - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient( + request, + "/api/v1/evaluations/tts/runs", + { method: "POST", body: JSON.stringify(body) }, + ); + return NextResponse.json(data, { status }); } catch (error) { - console.error('Proxy error:', error); + console.error("Proxy error:", error); return NextResponse.json( - { error: 'Failed to forward request to backend', details: error instanceof Error ? error.message : String(error) }, - { status: 500 } + { + error: "Failed to forward request to backend", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, ); } } diff --git a/app/api/languages/route.ts b/app/api/languages/route.ts index 50b78050..53a35975 100644 --- a/app/api/languages/route.ts +++ b/app/api/languages/route.ts @@ -1,22 +1,14 @@ -import { NextResponse } from 'next/server'; +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse } from "next/server"; export async function GET(request: Request) { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; - const apiKey = request.headers.get('X-API-KEY'); - try { - const response = await fetch(`${backendUrl}/api/v1/languages`, { - headers: { - 'X-API-KEY': apiKey || '', - }, - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); + const { status, data } = await apiClient(request, "/api/v1/languages"); + return NextResponse.json(data, { status }); } catch (error) { return NextResponse.json( { success: false, error: error, data: null }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/lib/apiClient.ts b/app/lib/apiClient.ts index 3895f4ef..790743ee 100644 --- a/app/lib/apiClient.ts +++ b/app/lib/apiClient.ts @@ -43,7 +43,9 @@ export async function apiFetch( options: RequestInit = {}, ): Promise { const headers = new Headers(options.headers); - headers.set("Content-Type", "application/json"); + if (!(options.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } headers.set("X-API-KEY", apiKey); const res = await fetch(url, { ...options, diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index 822cd718..f28c017a 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -1,9 +1,19 @@ -'use client'; +"use client"; -import { createContext, useContext, useState, useCallback } from 'react'; -import type { APIKey } from '@/app/keystore/page'; +import { + createContext, + useContext, + useCallback, + useSyncExternalStore, +} from "react"; +import type { APIKey } from "@/app/keystore/page"; -const STORAGE_KEY = 'kaapi_api_keys'; +const STORAGE_KEY = "kaapi_api_keys"; +const STORAGE_EVENT = "kaapi_api_keys_changed"; +const EMPTY_KEYS: APIKey[] = []; + +let cachedRaw: string | null = null; +let cachedKeys: APIKey[] = EMPTY_KEYS; interface AuthContextValue { apiKeys: APIKey[]; @@ -15,31 +25,89 @@ interface AuthContextValue { const AuthContext = createContext(null); +const readStoredKeys = (): APIKey[] => { + if (typeof window === "undefined") return EMPTY_KEYS; + + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === cachedRaw) { + return cachedKeys; + } + + try { + cachedRaw = raw; + cachedKeys = raw ? JSON.parse(raw) : EMPTY_KEYS; + return cachedKeys; + } catch { + cachedRaw = raw; + cachedKeys = EMPTY_KEYS; + return cachedKeys; + } +}; + +const getServerSnapshot = () => EMPTY_KEYS; + +const subscribeToKeyChanges = (onStoreChange: () => void) => { + if (typeof window === "undefined") return () => {}; + + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) { + onStoreChange(); + } + }; + + const handleCustomChange = () => { + onStoreChange(); + }; + + window.addEventListener("storage", handleStorage); + window.addEventListener(STORAGE_EVENT, handleCustomChange); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(STORAGE_EVENT, handleCustomChange); + }; +}; + export function AuthProvider({ children }: { children: React.ReactNode }) { - const [apiKeys, setApiKeys] = useState(() => { - if (typeof window === 'undefined') return []; - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) return JSON.parse(stored); - } catch { /* ignore malformed data */ } - return []; - }); + const apiKeys = useSyncExternalStore( + subscribeToKeyChanges, + readStoredKeys, + getServerSnapshot, + ); const persist = useCallback((keys: APIKey[]) => { - setApiKeys(keys); if (keys.length > 0) { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); + cachedRaw = localStorage.getItem(STORAGE_KEY); + cachedKeys = keys; } else { localStorage.removeItem(STORAGE_KEY); + cachedRaw = null; + cachedKeys = EMPTY_KEYS; } + window.dispatchEvent(new Event(STORAGE_EVENT)); }, []); - const addKey = useCallback((key: APIKey) => persist([...apiKeys, key]), [apiKeys, persist]); - const removeKey = useCallback((id: string) => persist(apiKeys.filter(k => k.id !== id)), [apiKeys, persist]); + const addKey = useCallback( + (key: APIKey) => persist([...apiKeys, key]), + [apiKeys, persist], + ); + const removeKey = useCallback( + (id: string) => persist(apiKeys.filter((k) => k.id !== id)), + [apiKeys, persist], + ); const setKeys = useCallback((keys: APIKey[]) => persist(keys), [persist]); return ( - + {children} ); @@ -47,6 +115,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { export function useAuth() { const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; } diff --git a/app/lib/useConfigs.ts b/app/lib/useConfigs.ts index 23851120..ba97ec0d 100644 --- a/app/lib/useConfigs.ts +++ b/app/lib/useConfigs.ts @@ -8,7 +8,7 @@ * - In-memory cache to avoid redundant fetches within same session */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from "react"; import { ConfigPublic, ConfigVersionPublic, @@ -16,8 +16,9 @@ import { ConfigListResponse, ConfigVersionListResponse, ConfigVersionResponse, -} from './configTypes'; -import { useAuth } from '@/app/lib/context/AuthContext'; +} from "./configTypes"; +import { apiFetch } from "./apiClient"; +import { useAuth } from "@/app/lib/context/AuthContext"; // ============ TYPES ============ @@ -33,7 +34,7 @@ export interface SavedConfig { promptContent: string; // Same as instructions for compatibility modelName: string; provider: string; - type: 'text' | 'stt' | 'tts'; // Config type - always present in UI (defaults to 'text') + type: "text" | "stt" | "tts"; // Config type - always present in UI (defaults to 'text') temperature: number; vectorStoreIds: string; tools?: Tool[]; @@ -60,7 +61,7 @@ interface ConfigCache { // ============ CONSTANTS ============ -const CACHE_KEY = 'kaapi_configs_cache'; +const CACHE_KEY = "kaapi_configs_cache"; const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes - cache is considered stale after this // ============ HELPER FUNCTIONS ============ @@ -68,7 +69,7 @@ const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes - cache is considered stale // Flatten config version for UI const flattenConfigVersion = ( config: ConfigPublic, - version: ConfigVersionPublic + version: ConfigVersionPublic, ): SavedConfig => { const blob = version.config_blob; const params = blob.completion.params; @@ -86,9 +87,10 @@ const flattenConfigVersion = ( : [params.knowledge_base_ids]; kbIds.forEach((kbId: string) => { - if (kbId) { // Only add non-empty IDs + if (kbId) { + // Only add non-empty IDs tools.push({ - type: 'file_search', + type: "file_search", knowledge_base_ids: [kbId], // Each tool gets one ID for UI max_num_results: params.max_num_results || 20, }); @@ -103,13 +105,13 @@ const flattenConfigVersion = ( description: config.description, version: version.version, timestamp: version.inserted_at, - instructions: params.instructions || '', - promptContent: params.instructions || '', - modelName: params.model || '', + instructions: params.instructions || "", + promptContent: params.instructions || "", + modelName: params.model || "", provider: blob.completion.provider, - type: blob.completion.type || 'text', // Default to 'text' for backward compatibility + type: blob.completion.type || "text", // Default to 'text' for backward compatibility temperature: params.temperature ?? 0.7, - vectorStoreIds: tools[0]?.knowledge_base_ids?.[0] || '', + vectorStoreIds: tools[0]?.knowledge_base_ids?.[0] || "", tools: tools, commit_message: version.commit_message, }; @@ -143,37 +145,37 @@ const groupConfigs = (configs: SavedConfig[]): ConfigGroup[] => { // Load cache from localStorage const loadCache = (): ConfigCache | null => { - if (typeof window === 'undefined') return null; + if (typeof window === "undefined") return null; try { const cached = localStorage.getItem(CACHE_KEY); if (cached) { return JSON.parse(cached); } } catch (e) { - console.error('Failed to load config cache:', e); + console.error("Failed to load config cache:", e); } return null; }; // Save cache to localStorage const saveCache = (cache: ConfigCache): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (e) { - console.error('Failed to save config cache:', e); + console.error("Failed to save config cache:", e); } }; // Clear cache export const clearConfigCache = (): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { localStorage.removeItem(CACHE_KEY); // Also clear in-memory cache inMemoryCache = null; } catch (e) { - console.error('Failed to clear config cache:', e); + console.error("Failed to clear config cache:", e); } }; @@ -200,151 +202,157 @@ export function useConfigs(): UseConfigsResult { const fetchInProgress = useRef(false); // Store refetch function in ref for background validation - const refetchRef = useRef<((force: boolean) => Promise) | undefined>(undefined); - - const fetchConfigs = useCallback(async (force: boolean = false) => { - // Prevent concurrent fetches - if (fetchInProgress.current) return; - - const apiKey = activeKey?.key ?? null; - if (!apiKey) { - setError('No API key found. Please add an API key in the Keystore.'); - setIsLoading(false); - return; - } - - // Validate cache in background without blocking UI - const validateCacheInBackground = async (cache: ConfigCache) => { - try { - // Fetch just the config list (lightweight call) - const response = await fetch('/api/configs', { - headers: { 'X-API-KEY': apiKey }, - }); - const data: ConfigListResponse = await response.json(); + const refetchRef = useRef<((force: boolean) => Promise) | undefined>( + undefined, + ); + + const fetchConfigs = useCallback( + async (force: boolean = false) => { + // Prevent concurrent fetches + if (fetchInProgress.current) return; + + const apiKey = activeKey?.key ?? null; + if (!apiKey) { + setError("No API key found. Please add an API key in the Keystore."); + setIsLoading(false); + return; + } - if (!data.success || !data.data) return; + // Validate cache in background without blocking UI + const validateCacheInBackground = async (cache: ConfigCache) => { + try { + // Fetch just the config list (lightweight call) + const data = await apiFetch( + "/api/configs", + apiKey, + ); - // Check if any config has been updated or new configs added - let needsRefresh = false; - const currentMeta = cache.configMeta; + if (!data.success || !data.data) return; - // Check for new or updated configs - for (const config of data.data) { - const cached = currentMeta[config.id]; - if (!cached) { - needsRefresh = true; - break; - } - if (cached.updated_at !== config.updated_at) { - needsRefresh = true; - break; - } - } + // Check if any config has been updated or new configs added + let needsRefresh = false; + const currentMeta = cache.configMeta; - // Check for deleted configs - if (!needsRefresh) { - const currentIds = new Set(data.data.map(c => c.id)); - for (const cachedId of Object.keys(currentMeta)) { - if (!currentIds.has(cachedId)) { + // Check for new or updated configs + for (const config of data.data) { + const cached = currentMeta[config.id]; + if (!cached) { + needsRefresh = true; + break; + } + if (cached.updated_at !== config.updated_at) { needsRefresh = true; break; } } - } - // Also check version counts by fetching version lists - if (!needsRefresh) { - for (const config of data.data) { - const cached = currentMeta[config.id]; - if (cached) { - try { - const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, { - headers: { 'X-API-KEY': apiKey }, - }); - const versionsData: ConfigVersionListResponse = await versionsResponse.json(); - if (versionsData.success && versionsData.data) { - if (versionsData.data.length !== cached.version_count) { - needsRefresh = true; - break; - } - } - } catch { - // If we can't check, assume we need refresh to be safe + // Check for deleted configs + if (!needsRefresh) { + const currentIds = new Set(data.data.map((c) => c.id)); + for (const cachedId of Object.keys(currentMeta)) { + if (!currentIds.has(cachedId)) { needsRefresh = true; break; } } } - } - if (needsRefresh) { - inMemoryCache = null; - fetchInProgress.current = false; - if (refetchRef.current) { - await refetchRef.current(true); + // Also check version counts by fetching version lists + if (!needsRefresh) { + for (const config of data.data) { + const cached = currentMeta[config.id]; + if (cached) { + try { + const versionsData = + await apiFetch( + `/api/configs/${config.id}/versions`, + apiKey, + ); + if (versionsData.success && versionsData.data) { + if (versionsData.data.length !== cached.version_count) { + needsRefresh = true; + break; + } + } + } catch { + // If we can't check, assume we need refresh to be safe + needsRefresh = true; + break; + } + } + } + } + + if (needsRefresh) { + inMemoryCache = null; + fetchInProgress.current = false; + if (refetchRef.current) { + await refetchRef.current(true); + } } + } catch { + console.error("Failed to validate cache"); } - } catch { - console.error('Failed to validate cache'); - } - }; - - // Check in-memory cache first (fastest) - if (!force && inMemoryCache) { - const cacheAge = Date.now() - inMemoryCache.cachedAt; - if (cacheAge < CACHE_MAX_AGE_MS) { - setConfigs(inMemoryCache.configs); - setIsCached(true); - setIsLoading(false); - // Validate cache in background - validateCacheInBackground(inMemoryCache); - return; - } - } + }; - // Check localStorage cache - if (!force) { - const cache = loadCache(); - if (cache) { - const cacheAge = Date.now() - cache.cachedAt; + // Check in-memory cache first (fastest) + if (!force && inMemoryCache) { + const cacheAge = Date.now() - inMemoryCache.cachedAt; if (cacheAge < CACHE_MAX_AGE_MS) { - setConfigs(cache.configs); + setConfigs(inMemoryCache.configs); setIsCached(true); setIsLoading(false); - inMemoryCache = cache; // Validate cache in background - validateCacheInBackground(cache); + validateCacheInBackground(inMemoryCache); return; } } - } - // No valid cache, fetch from API - fetchInProgress.current = true; - setIsLoading(true); - setError(null); - setIsCached(false); - - try { - const result = await fetchAllConfigs(apiKey); - setConfigs(result.configs); - - // Save to cache - const newCache: ConfigCache = { - configs: result.configs, - configMeta: result.configMeta, - cachedAt: Date.now(), - }; - saveCache(newCache); - inMemoryCache = newCache; - } catch { - console.error('Failed to load saved configs'); - setError('Failed to load configurations. Please try again.'); - } finally { - setIsLoading(false); - fetchInProgress.current = false; - } - }, [activeKey]); + // Check localStorage cache + if (!force) { + const cache = loadCache(); + if (cache) { + const cacheAge = Date.now() - cache.cachedAt; + if (cacheAge < CACHE_MAX_AGE_MS) { + setConfigs(cache.configs); + setIsCached(true); + setIsLoading(false); + inMemoryCache = cache; + // Validate cache in background + validateCacheInBackground(cache); + return; + } + } + } + + // No valid cache, fetch from API + fetchInProgress.current = true; + setIsLoading(true); + setError(null); + setIsCached(false); + + try { + const result = await fetchAllConfigs(apiKey); + setConfigs(result.configs); + + // Save to cache + const newCache: ConfigCache = { + configs: result.configs, + configMeta: result.configMeta, + cachedAt: Date.now(), + }; + saveCache(newCache); + inMemoryCache = newCache; + } catch { + console.error("Failed to load saved configs"); + setError("Failed to load configurations. Please try again."); + } finally { + setIsLoading(false); + fetchInProgress.current = false; + } + }, + [activeKey], + ); refetchRef.current = fetchConfigs; useEffect(() => { @@ -370,17 +378,17 @@ interface FetchResult { async function fetchAllConfigs(apiKey: string): Promise { // Fetch all configs - const response = await fetch('/api/configs', { - headers: { 'X-API-KEY': apiKey }, - }); - const data: ConfigListResponse = await response.json(); + const data = await apiFetch("/api/configs", apiKey); if (!data.success || !data.data) { - throw new Error(data.error || 'Failed to fetch configs'); + throw new Error(data.error || "Failed to fetch configs"); } const allVersions: SavedConfig[] = []; - const configMeta: Record = {}; + const configMeta: Record< + string, + { updated_at: string; version_count: number } + > = {}; // Fetch versions for all configs in parallel (batched) const BATCH_SIZE = 5; // Fetch 5 configs at a time @@ -390,10 +398,10 @@ async function fetchAllConfigs(apiKey: string): Promise { const batch = configs.slice(i, i + BATCH_SIZE); const batchPromises = batch.map(async (config) => { try { - const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, { - headers: { 'X-API-KEY': apiKey }, - }); - const versionsData: ConfigVersionListResponse = await versionsResponse.json(); + const versionsData = await apiFetch( + `/api/configs/${config.id}/versions`, + apiKey, + ); if (versionsData.success && versionsData.data) { // Store metadata for cache validation @@ -405,17 +413,19 @@ async function fetchAllConfigs(apiKey: string): Promise { // Fetch all version details in parallel const versionPromises = versionsData.data.map(async (versionItem) => { try { - const versionResponse = await fetch( + const versionData = await apiFetch( `/api/configs/${config.id}/versions/${versionItem.version}`, - { headers: { 'X-API-KEY': apiKey } } + apiKey, ); - const versionData: ConfigVersionResponse = await versionResponse.json(); if (versionData.success && versionData.data) { return flattenConfigVersion(config, versionData.data); } } catch (e) { - console.error(`Failed to fetch version ${versionItem.version}:`, e); + console.error( + `Failed to fetch version ${versionItem.version}:`, + e, + ); } return null; }); @@ -430,7 +440,7 @@ async function fetchAllConfigs(apiKey: string): Promise { }); const batchResults = await Promise.all(batchPromises); - batchResults.forEach(versions => allVersions.push(...versions)); + batchResults.forEach((versions) => allVersions.push(...versions)); } return { configs: allVersions, configMeta }; @@ -444,12 +454,15 @@ export const formatRelativeTime = (timestamp: string | number): string => { const now = Date.now(); let date: number; - if (typeof timestamp === 'string') { + if (typeof timestamp === "string") { // If timestamp doesn't include timezone info, assume it's UTC // and append 'Z' to ensure it's interpreted as UTC - const utcTimestamp = timestamp.endsWith('Z') || timestamp.includes('+') || timestamp.includes('T') && timestamp.split('T')[1].includes('-') - ? timestamp - : timestamp + 'Z'; + const utcTimestamp = + timestamp.endsWith("Z") || + timestamp.includes("+") || + (timestamp.includes("T") && timestamp.split("T")[1].includes("-")) + ? timestamp + : timestamp + "Z"; date = new Date(utcTimestamp).getTime(); } else { date = timestamp; @@ -460,7 +473,7 @@ export const formatRelativeTime = (timestamp: string | number): string => { const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (minutes < 1) return 'just now'; + if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 30) return `${days}d ago`; diff --git a/app/speech-to-text/page.tsx b/app/speech-to-text/page.tsx index 3937d167..db987e66 100644 --- a/app/speech-to-text/page.tsx +++ b/app/speech-to-text/page.tsx @@ -5,22 +5,24 @@ * Tab 2 - Evaluations: Run and monitor STT evaluations */ -"use client" -import { useState, useEffect, useRef } from 'react'; -import { colors } from '@/app/lib/colors'; -import Sidebar from '@/app/components/Sidebar'; -import TabNavigation from '@/app/components/TabNavigation'; -import StatusBadge from '@/app/components/StatusBadge'; -import Loader, { LoaderBox } from '@/app/components/Loader'; -import { useToast } from '@/app/components/Toast'; -import { useAuth } from '@/app/lib/context/AuthContext'; -import { useApp } from '@/app/lib/context/AppContext'; -import type { APIKey } from '@/app/keystore/page'; -import WaveformVisualizer from '@/app/components/speech-to-text/WaveformVisualizer'; -import { computeWordDiff } from '@/app/components/speech-to-text/TranscriptionDiffViewer'; -import ErrorModal from '@/app/components/ErrorModal'; - -type Tab = 'datasets' | 'evaluations'; +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { colors } from "@/app/lib/colors"; +import Sidebar from "@/app/components/Sidebar"; +import TabNavigation from "@/app/components/TabNavigation"; +import StatusBadge from "@/app/components/StatusBadge"; +import Loader, { LoaderBox } from "@/app/components/Loader"; +import { useToast } from "@/app/components/Toast"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useApp } from "@/app/lib/context/AppContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import type { APIKey } from "@/app/keystore/page"; +import WaveformVisualizer from "@/app/components/speech-to-text/WaveformVisualizer"; +import { computeWordDiff } from "@/app/components/speech-to-text/TranscriptionDiffViewer"; +import ErrorModal from "@/app/components/ErrorModal"; + +type Tab = "datasets" | "evaluations"; // Types interface AudioFile { @@ -32,7 +34,7 @@ interface AudioFile { mediaType: string; groundTruth: string; languageId: number; - fileId?: string; // Backend file ID after upload + fileId?: string; } interface Dataset { @@ -124,14 +126,14 @@ function AudioPlayer({ const handleTimeUpdate = () => setCurrentTime(audio.currentTime); const handleEnded = () => onPlayToggle(); - audio.addEventListener('loadedmetadata', handleLoadedMetadata); - audio.addEventListener('timeupdate', handleTimeUpdate); - audio.addEventListener('ended', handleEnded); + audio.addEventListener("loadedmetadata", handleLoadedMetadata); + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("ended", handleEnded); return () => { - audio.removeEventListener('loadedmetadata', handleLoadedMetadata); - audio.removeEventListener('timeupdate', handleTimeUpdate); - audio.removeEventListener('ended', handleEnded); + audio.removeEventListener("loadedmetadata", handleLoadedMetadata); + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("ended", handleEnded); }; }, [onPlayToggle]); @@ -147,7 +149,7 @@ function AudioPlayer({ const formatTime = (time: number) => { const mins = Math.floor(time / 60); const secs = Math.floor(time % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; + return `${mins}:${secs.toString().padStart(2, "0")}`; }; return ( @@ -159,8 +161,10 @@ function AudioPlayer({ onClick={onPlayToggle} className="w-8 h-8 flex items-center justify-center rounded-full flex-shrink-0" style={{ - backgroundColor: isPlaying ? colors.accent.primary : colors.bg.secondary, - color: isPlaying ? '#fff' : colors.text.primary, + backgroundColor: isPlaying + ? colors.accent.primary + : colors.bg.secondary, + color: isPlaying ? "#fff" : colors.text.primary, }} > {isPlaying ? ( @@ -186,7 +190,10 @@ function AudioPlayer({ {/* eslint-enable react-hooks/refs */} - + {isPlaying ? formatTime(currentTime) : formatTime(duration)} @@ -199,9 +206,9 @@ function AudioPlayer({
0 ? `${(currentTime / duration) * 100}%` : '0%', + width: duration > 0 ? `${(currentTime / duration) * 100}%` : "0%", backgroundColor: colors.accent.primary, - transition: 'width 0.1s linear', + transition: "width 0.1s linear", }} />
@@ -233,14 +240,14 @@ function AudioPlayerFromUrl({ const handleTimeUpdate = () => setCurrentTime(audio.currentTime); const handleEnded = () => onPlayToggle(); - audio.addEventListener('loadedmetadata', handleLoadedMetadata); - audio.addEventListener('timeupdate', handleTimeUpdate); - audio.addEventListener('ended', handleEnded); + audio.addEventListener("loadedmetadata", handleLoadedMetadata); + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("ended", handleEnded); return () => { - audio.removeEventListener('loadedmetadata', handleLoadedMetadata); - audio.removeEventListener('timeupdate', handleTimeUpdate); - audio.removeEventListener('ended', handleEnded); + audio.removeEventListener("loadedmetadata", handleLoadedMetadata); + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("ended", handleEnded); }; }, [onPlayToggle]); @@ -263,24 +270,35 @@ function AudioPlayerFromUrl({ className="w-6 h-6 flex items-center justify-center rounded-full flex-shrink-0 border-2" style={{ borderColor: colors.accent.primary, - backgroundColor: 'transparent', + backgroundColor: "transparent", color: colors.accent.primary, }} > {isPlaying ? ( - + ) : ( - + )} {sampleName && ( -
+
{sampleName}
)} @@ -294,9 +312,9 @@ function AudioPlayerFromUrl({
0 ? `${(currentTime / duration) * 100}%` : '0%', + width: duration > 0 ? `${(currentTime / duration) * 100}%` : "0%", backgroundColor: colors.accent.primary, - transition: 'width 0.05s ease-out', + transition: "width 0.05s ease-out", }} />
@@ -311,20 +329,20 @@ interface Language { } const DEFAULT_LANGUAGES: Language[] = [ - { id: 1, code: 'en', name: 'English' }, - { id: 2, code: 'hi', name: 'Hindi' }, + { id: 1, code: "en", name: "English" }, + { id: 2, code: "hi", name: "Hindi" }, ]; export default function SpeechToTextPage() { const toast = useToast(); - const [activeTab, setActiveTab] = useState('datasets'); + const [activeTab, setActiveTab] = useState("datasets"); const { sidebarCollapsed, setSidebarCollapsed } = useApp(); const [leftPanelWidth] = useState(450); const { apiKeys } = useAuth(); const [languages, setLanguages] = useState([]); - const [datasetName, setDatasetName] = useState(''); - const [datasetDescription, setDatasetDescription] = useState(''); + const [datasetName, setDatasetName] = useState(""); + const [datasetDescription, setDatasetDescription] = useState(""); const [datasetLanguageId, setDatasetLanguageId] = useState(1); const [audioFiles, setAudioFiles] = useState([]); const [playingFileId, setPlayingFileId] = useState(null); @@ -335,9 +353,11 @@ export default function SpeechToTextPage() { const [isLoadingDatasets, setIsLoadingDatasets] = useState(false); // Evaluation form (Tab 2) - const [evaluationName, setEvaluationName] = useState(''); - const [selectedDatasetId, setSelectedDatasetId] = useState(null); - const [selectedModel, setSelectedModel] = useState('gemini-2.5-pro'); + const [evaluationName, setEvaluationName] = useState(""); + const [selectedDatasetId, setSelectedDatasetId] = useState( + null, + ); + const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro"); const [isRunning, setIsRunning] = useState(false); // Evaluation runs (Tab 2) @@ -351,20 +371,15 @@ export default function SpeechToTextPage() { // Error modal state const [errorModalOpen, setErrorModalOpen] = useState(false); - const [errorModalMessage, setErrorModalMessage] = useState(''); + const [errorModalMessage, setErrorModalMessage] = useState(""); // Load languages const loadLanguages = async () => { if (apiKeys.length === 0) return; try { - const response = await fetch('/api/languages', { - headers: { 'X-API-KEY': apiKeys[0].key }, - }); - - if (!response.ok) throw new Error('Failed to load languages'); - - const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await apiFetch("/api/languages", apiKeys[0].key); // eslint-disable-next-line @typescript-eslint/no-explicit-any let rawList: any[] = []; @@ -384,8 +399,8 @@ export default function SpeechToTextPage() { // eslint-disable-next-line @typescript-eslint/no-explicit-any .map((l: any) => ({ id: l.id, - code: l.locale || l.code || '', - name: l.label || l.name || '', + code: l.locale || l.code || "", + name: l.label || l.name || "", })); if (languagesList.length > 0) { @@ -398,7 +413,7 @@ export default function SpeechToTextPage() { setLanguages(DEFAULT_LANGUAGES); } } catch (error) { - console.error('Failed to load languages:', error); + console.error("Failed to load languages:", error); setLanguages(DEFAULT_LANGUAGES); } }; @@ -409,13 +424,11 @@ export default function SpeechToTextPage() { setIsLoadingDatasets(true); try { - const response = await fetch('/api/evaluations/stt/datasets', { - headers: { 'X-API-KEY': apiKeys[0].key }, - }); - - if (!response.ok) throw new Error('Failed to load datasets'); - - const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await apiFetch( + "/api/evaluations/stt/datasets", + apiKeys[0].key, + ); let datasetsList = []; if (Array.isArray(data)) { @@ -428,8 +441,8 @@ export default function SpeechToTextPage() { setDatasets(datasetsList); } catch (error) { - console.error('Failed to load datasets:', error); - toast.error('Failed to load datasets'); + console.error("Failed to load datasets:", error); + toast.error("Failed to load datasets"); setDatasets([]); } finally { setIsLoadingDatasets(false); @@ -442,13 +455,11 @@ export default function SpeechToTextPage() { setIsLoadingRuns(true); try { - const response = await fetch('/api/evaluations/stt/runs', { - headers: { 'X-API-KEY': apiKeys[0].key }, - }); - - if (!response.ok) throw new Error('Failed to load runs'); - - const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await apiFetch( + "/api/evaluations/stt/runs", + apiKeys[0].key, + ); let runsList = []; if (Array.isArray(data)) { @@ -461,8 +472,8 @@ export default function SpeechToTextPage() { setRuns(runsList); } catch (error) { - console.error('Failed to load runs:', error); - toast.error('Failed to load evaluation runs'); + console.error("Failed to load runs:", error); + toast.error("Failed to load evaluation runs"); setRuns([]); } finally { setIsLoadingRuns(false); @@ -472,27 +483,29 @@ export default function SpeechToTextPage() { useEffect(() => { loadLanguages(); loadDatasets(); - if (activeTab === 'evaluations') { + if (activeTab === "evaluations") { loadRuns(); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKeys, activeTab]); // Handle audio file selection and upload - const handleAudioFileSelect = async (event: React.ChangeEvent) => { + const handleAudioFileSelect = async ( + event: React.ChangeEvent, + ) => { const files = event.target.files; if (!files || files.length === 0) return; if (apiKeys.length === 0) { - toast.error('Please add an API key in Keystore first'); + toast.error("Please add an API key in Keystore first"); return; } - const validTypes = ['.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm']; + const validTypes = [".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"]; for (const file of Array.from(files)) { - if (!validTypes.some(ext => file.name.toLowerCase().endsWith(ext))) { + if (!validTypes.some((ext) => file.name.toLowerCase().endsWith(ext))) { toast.error(`${file.name}: Invalid file type`); continue; } @@ -501,7 +514,7 @@ export default function SpeechToTextPage() { const base64Promise = new Promise((resolve, reject) => { reader.onload = () => { const result = reader.result as string; - const base64 = result.split(',')[1]; + const base64 = result.split(",")[1]; resolve(base64); }; reader.onerror = reject; @@ -512,105 +525,116 @@ export default function SpeechToTextPage() { const base64 = await base64Promise; const localId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - setAudioFiles(prev => [...prev, { - id: localId, - file, - name: file.name, - size: file.size, - base64, - mediaType: file.type || 'audio/mpeg', - groundTruth: '', - languageId: datasetLanguageId, - fileId: undefined, - }]); + setAudioFiles((prev) => [ + ...prev, + { + id: localId, + file, + name: file.name, + size: file.size, + base64, + mediaType: file.type || "audio/mpeg", + groundTruth: "", + languageId: datasetLanguageId, + fileId: undefined, + }, + ]); const formData = new FormData(); - formData.append('file', file); - - const uploadResponse = await fetch('/api/evaluations/stt/files', { - method: 'POST', - headers: { 'X-API-KEY': apiKeys[0].key }, + formData.append("file", file); + + const uploadData = await apiFetch<{ + file_id?: string; + id?: string; + data?: { file_id?: string; id?: string }; + }>("/api/evaluations/stt/files", apiKeys[0].key, { + method: "POST", body: formData, }); - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - console.error(`Upload failed with status ${uploadResponse.status}:`, errorText); - throw new Error(`Upload failed: ${uploadResponse.status}`); - } - - const uploadData = await uploadResponse.json(); - - const backendFileId = uploadData.file_id || uploadData.id || uploadData.data?.file_id || uploadData.data?.id; + const backendFileId = + uploadData.file_id || + uploadData.id || + uploadData.data?.file_id || + uploadData.data?.id; if (!backendFileId) { - console.error('No file ID found in response. Full response:', JSON.stringify(uploadData, null, 2)); - throw new Error(`No file ID returned from backend. Response: ${JSON.stringify(uploadData)}`); + console.error( + "No file ID found in response. Full response:", + JSON.stringify(uploadData, null, 2), + ); + throw new Error( + `No file ID returned from backend. Response: ${JSON.stringify(uploadData)}`, + ); } - setAudioFiles(prev => prev.map(f => - f.id === localId ? { ...f, fileId: backendFileId } : f - )); + setAudioFiles((prev) => + prev.map((f) => + f.id === localId ? { ...f, fileId: backendFileId } : f, + ), + ); // toast.success(`${file.name} uploaded`); // Removed toast notification } catch (error) { console.error(`Error uploading ${file.name}:`, error); toast.error(`Failed to upload ${file.name}`); - setAudioFiles(prev => prev.filter(f => f.name !== file.name)); + setAudioFiles((prev) => prev.filter((f) => f.name !== file.name)); } } - event.target.value = ''; + event.target.value = ""; }; const triggerAudioUpload = () => { - const input = document.getElementById('audio-upload') as HTMLInputElement; + const input = document.getElementById("audio-upload") as HTMLInputElement; if (input) input.click(); }; const removeAudioFile = (id: string) => { - setAudioFiles(prev => prev.filter(f => f.id !== id)); + setAudioFiles((prev) => prev.filter((f) => f.id !== id)); if (playingFileId === id) setPlayingFileId(null); }; const updateGroundTruth = (id: string, groundTruth: string) => { - setAudioFiles(prev => prev.map(f => - f.id === id ? { ...f, groundTruth } : f - )); + setAudioFiles((prev) => + prev.map((f) => (f.id === id ? { ...f, groundTruth } : f)), + ); }; const updateFileLanguage = (id: string, languageId: number) => { - setAudioFiles(prev => prev.map(f => - f.id === id ? { ...f, languageId } : f - )); + setAudioFiles((prev) => + prev.map((f) => (f.id === id ? { ...f, languageId } : f)), + ); }; const handleCreateDataset = async () => { if (!datasetName.trim()) { - toast.error('Please enter a dataset name'); + toast.error("Please enter a dataset name"); return; } if (audioFiles.length === 0) { - toast.error('Please add at least one audio file'); + toast.error("Please add at least one audio file"); return; } if (apiKeys.length === 0) { - toast.error('Please add an API key in Keystore first'); + toast.error("Please add an API key in Keystore first"); return; } - const filesNotUploaded = audioFiles.filter(f => !f.fileId); + const filesNotUploaded = audioFiles.filter((f) => !f.fileId); if (filesNotUploaded.length > 0) { - toast.error(`${filesNotUploaded.length} file(s) still uploading. Please wait...`); + toast.error( + `${filesNotUploaded.length} file(s) still uploading. Please wait...`, + ); return; } setIsCreating(true); try { - const samples = audioFiles.map(audioFile => ({ + const samples = audioFiles.map((audioFile) => ({ file_id: audioFile.fileId!, ground_truth: audioFile.groundTruth.trim() || undefined, language_id: audioFile.languageId, @@ -622,34 +646,25 @@ export default function SpeechToTextPage() { language_id: datasetLanguageId, samples: samples, }; - const createDatasetResponse = await fetch('/api/evaluations/stt/datasets', { - method: 'POST', - headers: { - 'X-API-KEY': apiKeys[0].key, - 'Content-Type': 'application/json', - }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await apiFetch("/api/evaluations/stt/datasets", apiKeys[0].key, { + method: "POST", body: JSON.stringify(payload), }); - if (!createDatasetResponse.ok) { - const errorData = await createDatasetResponse.json(); - const errorMessage = errorData.error || errorData.message || 'Failed to create dataset'; - throw new Error(errorMessage); - } - - await createDatasetResponse.json(); - toast.success(`Dataset "${datasetName}" created successfully!`); - setDatasetName(''); - setDatasetDescription(''); + setDatasetName(""); + setDatasetDescription(""); setDatasetLanguageId(1); setAudioFiles([]); await loadDatasets(); } catch (error) { - console.error('Failed to create dataset:', error); - toast.error(`Failed to create dataset: ${error instanceof Error ? error.message : 'Unknown error'}`); + console.error("Failed to create dataset:", error); + toast.error( + `Failed to create dataset: ${error instanceof Error ? error.message : "Unknown error"}`, + ); } finally { setIsCreating(false); } @@ -657,29 +672,26 @@ export default function SpeechToTextPage() { const handleRunEvaluation = async () => { if (apiKeys.length === 0) { - toast.error('Please add an API key in Keystore first'); + toast.error("Please add an API key in Keystore first"); return; } if (!selectedDatasetId) { - toast.error('Please select a dataset'); + toast.error("Please select a dataset"); return; } if (!evaluationName.trim()) { - toast.error('Please enter an evaluation name'); + toast.error("Please enter an evaluation name"); return; } setIsRunning(true); try { - const response = await fetch('/api/evaluations/stt/runs', { - method: 'POST', - headers: { - 'X-API-KEY': apiKeys[0].key, - 'Content-Type': 'application/json', - }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await apiFetch("/api/evaluations/stt/runs", apiKeys[0].key, { + method: "POST", body: JSON.stringify({ run_name: evaluationName.trim(), dataset_id: selectedDatasetId, @@ -687,24 +699,19 @@ export default function SpeechToTextPage() { }), }); - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = errorData.error || errorData.message || 'Failed to start evaluation'; - throw new Error(errorMessage); - } - - await response.json(); - // toast.success(`Evaluation "${evaluationName}" started successfully!`); - setSelectedModel('gemini-2.5-pro'); + setSelectedModel("gemini-2.5-pro"); - setEvaluationName(''); + setEvaluationName(""); setSelectedDatasetId(null); await loadRuns(); } catch (error) { - console.error('Failed to run evaluation:', error); - const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred while starting the evaluation'; + console.error("Failed to run evaluation:", error); + const errorMessage = + error instanceof Error + ? error.message + : "An unknown error occurred while starting the evaluation"; setErrorModalMessage(errorMessage); setErrorModalOpen(true); } finally { @@ -725,13 +732,11 @@ export default function SpeechToTextPage() { setIsLoadingResults(true); try { // Fetch run details with results - const runResponse = await fetch(`/api/evaluations/stt/runs/${runId}?include_results=true&include_signed_url=true`, { - headers: { 'X-API-KEY': apiKeys[0].key }, - }); - - if (!runResponse.ok) throw new Error('Failed to load results'); - - const runData = await runResponse.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const runData = await apiFetch( + `/api/evaluations/stt/runs/${runId}?include_results=true&include_signed_url=true`, + apiKeys[0].key, + ); // Extract results let resultsList = []; @@ -741,11 +746,14 @@ export default function SpeechToTextPage() { resultsList = runData.results; } else if (runData.data && Array.isArray(runData.data)) { resultsList = runData.data; - } else if (runData.data && runData.data.results && Array.isArray(runData.data.results)) { + } else if ( + runData.data && + runData.data.results && + Array.isArray(runData.data.results) + ) { resultsList = runData.data.results; } - // Enrich results with sample data (filename, ground truth, signed URL) // The structure is: data.results[].sample contains all sample information // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -753,14 +761,15 @@ export default function SpeechToTextPage() { const sample = result.sample; // Extract sample name from sample_metadata.original_filename - const sampleName = sample?.sample_metadata?.original_filename || + const sampleName = + sample?.sample_metadata?.original_filename || `Sample ${result.stt_sample_id}`; // Extract ground truth - const groundTruth = sample?.ground_truth || ''; + const groundTruth = sample?.ground_truth || ""; // Extract signed URL - const signedUrl = sample?.signed_url || ''; + const signedUrl = sample?.signed_url || ""; // Extract file ID const fileId = sample?.file_id; @@ -770,25 +779,28 @@ export default function SpeechToTextPage() { sampleName, groundTruth, fileId, - signedUrl + signedUrl, }; }); setResults(resultsList); setSelectedRunId(runId); } catch (error) { - console.error('Failed to load results:', error); - toast.error('Failed to load evaluation results'); + console.error("Failed to load results:", error); + toast.error("Failed to load evaluation results"); setResults([]); } finally { setIsLoadingResults(false); } }; - const selectedDataset = datasets.find(d => d.id === selectedDatasetId); + const selectedDataset = datasets.find((d) => d.id === selectedDatasetId); return ( -
+
@@ -796,7 +808,10 @@ export default function SpeechToTextPage() { {/* Header */}
-

+

Speech-to-Text Evaluation

@@ -822,8 +850,8 @@ export default function SpeechToTextPage() { {/* Tab Navigation */} setActiveTab(tabId as Tab)} @@ -831,19 +859,51 @@ export default function SpeechToTextPage() { {/* Tab Content */} {apiKeys.length === 0 ? ( -

+
- - + + -

API key required

-

Add an API key in the Keystore to start creating datasets and running evaluations

- +

+ API key required +

+

+ Add an API key in the Keystore to start creating datasets and + running evaluations +

+
Go to Keystore
- ) : activeTab === 'datasets' ? ( + ) : activeTab === "datasets" ? ( DESCRIPTION_CHAR_LIMIT; return ( -
+
{isLong && !expanded - ? description.slice(0, DESCRIPTION_CHAR_LIMIT).trimEnd() + '...' + ? description.slice(0, DESCRIPTION_CHAR_LIMIT).trimEnd() + "..." : description} {isLong && ( )}
@@ -1017,66 +1083,94 @@ function DatasetsTab({ if (apiKeys.length === 0) return; setViewingId(datasetId); try { - const response = await fetch( + const data = await apiFetch<{ + data?: { + samples?: { + id: number; + text: string; + ground_truth: string; + language_id: number; + signed_url?: string; + sample_metadata?: { + original_filename?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + }; + }[]; + }; + samples?: { + id: number; + text: string; + ground_truth: string; + language_id: number; + signed_url?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sample_metadata?: { original_filename?: string; [key: string]: any }; + }[]; + }>( `/api/evaluations/stt/datasets/${datasetId}?include_samples=true&include_signed_url=true&sample_limit=100&sample_offset=0`, - { headers: { 'X-API-KEY': apiKeys[0].key } } + apiKeys[0].key, ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to fetch dataset'); - } - const data = await response.json(); const samples = data?.data?.samples || data?.samples || []; if (samples.length === 0) { - toast.error('No samples found in this dataset'); + toast.error("No samples found in this dataset"); return; } setViewModalData({ name: datasetName, datasetId, samples }); } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Failed to view dataset'); + toast.error( + err instanceof Error ? err.message : "Failed to view dataset", + ); } finally { setViewingId(null); } }; - const handleUpdateSample = async (sampleId: number, field: 'ground_truth' | 'language_id', value: string | number) => { + const handleUpdateSample = async ( + sampleId: number, + field: "ground_truth" | "language_id", + value: string | number, + ) => { if (!viewModalData || apiKeys.length === 0) return; setSavingSampleId(sampleId); try { - const response = await fetch( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await apiFetch( `/api/evaluations/stt/samples/${sampleId}`, + apiKeys[0].key, { - method: 'PATCH', - headers: { 'X-API-KEY': apiKeys[0].key, 'Content-Type': 'application/json' }, + method: "PATCH", body: JSON.stringify({ [field]: value }), - } + }, + ); + setViewModalData((prev) => + prev + ? { + ...prev, + samples: prev.samples.map((s) => + s.id === sampleId ? { ...s, [field]: value } : s, + ), + } + : null, ); - const responseData = await response.json().catch(() => ({})); - if (!response.ok) { - const msg = responseData?.detail || responseData?.error || responseData?.message || `Failed to update sample (${response.status})`; - throw new Error(msg); - } - setViewModalData(prev => prev ? { - ...prev, - samples: prev.samples.map(s => s.id === sampleId ? { ...s, [field]: value } : s), - } : null); } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Failed to update sample'); + toast.error( + err instanceof Error ? err.message : "Failed to update sample", + ); } finally { setSavingSampleId(null); } }; - useEffect(() => { if (!showLanguageInfo) return; const handleClick = () => setShowLanguageInfo(false); const handleScroll = () => setShowLanguageInfo(false); - document.addEventListener('click', handleClick); - window.addEventListener('scroll', handleScroll, true); + document.addEventListener("click", handleClick); + window.addEventListener("scroll", handleScroll, true); return () => { - document.removeEventListener('click', handleClick); - window.removeEventListener('scroll', handleScroll, true); + document.removeEventListener("click", handleClick); + window.removeEventListener("scroll", handleScroll, true); }; }, [showLanguageInfo]); @@ -1085,64 +1179,100 @@ function DatasetsTab({ {/* Left Panel - Create Dataset Form */}
{/* Page Title */}
-

+

Create New Dataset

-

+

Add audio samples that will be transcribed during evaluation

{/* Name */}
-
{/* Description */}
-
{/* Language */}
-
@@ -1181,7 +1337,10 @@ function DatasetsTab({ {/* Audio Samples */}
-
@@ -1202,17 +1361,39 @@ function DatasetsTab({ style={{ borderColor: colors.border, backgroundColor: colors.bg.primary, - cursor: apiKeys.length > 0 ? 'pointer' : 'not-allowed', + cursor: apiKeys.length > 0 ? "pointer" : "not-allowed", opacity: apiKeys.length > 0 ? 1 : 0.5, }} - onMouseEnter={(e) => apiKeys.length > 0 && (e.currentTarget.style.backgroundColor = colors.bg.secondary)} - onMouseLeave={(e) => apiKeys.length > 0 && (e.currentTarget.style.backgroundColor = colors.bg.primary)} + onMouseEnter={(e) => + apiKeys.length > 0 && + (e.currentTarget.style.backgroundColor = colors.bg.secondary) + } + onMouseLeave={(e) => + apiKeys.length > 0 && + (e.currentTarget.style.backgroundColor = colors.bg.primary) + } > - - + + -

- {apiKeys.length > 0 ? 'Click to upload audio samples' : 'Add an API key to upload'} +

+ {apiKeys.length > 0 + ? "Click to upload audio samples" + : "Add an API key to upload"}

MP3, WAV, M4A, OGG, FLAC, WebM @@ -1220,102 +1401,178 @@ function DatasetsTab({

) : (
-
- - {audioFiles.map((audioFile, idx) => ( -
-
- {/* Header: number, filename, status, remove */} -
-
- + {audioFiles.map((audioFile, idx) => ( +
+
+ {/* Header: number, filename, status, remove */} +
+
+ + {idx + 1} + + + {audioFile.name} + + + {formatFileSize(audioFile.size)} + + {audioFile.fileId ? ( + + + + ) : ( +
+ )} +
+
- -
- {/* Audio Player */} - setPlayingFileId(playingFileId === audioFile.id ? null : audioFile.id)} - /> + {/* Audio Player */} + + setPlayingFileId( + playingFileId === audioFile.id + ? null + : audioFile.id, + ) + } + /> - {/* Language + Ground Truth */} -
-
- - -
-
- -