diff --git a/app/api/credentials/[provider]/route.ts b/app/api/credentials/[provider]/route.ts index 3b9ebe79..81760212 100644 --- a/app/api/credentials/[provider]/route.ts +++ b/app/api/credentials/[provider]/route.ts @@ -13,7 +13,10 @@ export async function GET( ); return NextResponse.json(data, { status }); } catch (e: unknown) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); } } @@ -34,6 +37,9 @@ export async function DELETE( return NextResponse.json(data, { status }); } catch (e: unknown) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); } } diff --git a/app/api/credentials/route.ts b/app/api/credentials/route.ts index b062fd7c..627f2b2a 100644 --- a/app/api/credentials/route.ts +++ b/app/api/credentials/route.ts @@ -6,7 +6,10 @@ export async function GET(request: NextRequest) { const { status, data } = await apiClient(request, "/api/v1/credentials/"); return NextResponse.json(data, { status }); } catch (e: unknown) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); } } @@ -19,7 +22,10 @@ export async function POST(request: NextRequest) { }); return NextResponse.json(data, { status }); } catch (e: unknown) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); } } @@ -32,6 +38,9 @@ export async function PATCH(request: NextRequest) { }); return NextResponse.json(data, { status }); } catch (e: unknown) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); } } 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..05e70d66 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,69 @@ 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'); - - if (!apiKey) { - 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 }, - }); - let data; - try { data = await response.json(); } catch { data = { success: true }; } - return NextResponse.json(data, { status: response.ok ? 200 : response.status }); + const { data, status } = await apiClient( + request, + `/api/v1/evaluations/tts/datasets/${dataset_id}`, + { method: "DELETE" }, + ); + return NextResponse.json(data, { 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/hooks/useConfigs.ts b/app/hooks/useConfigs.ts index f4e94a3c..ffa893c4 100644 --- a/app/hooks/useConfigs.ts +++ b/app/hooks/useConfigs.ts @@ -1,4 +1,4 @@ -'use client'; +"use client"; /** * useConfigs — shared React hook for fetching and managing configurations. @@ -14,23 +14,33 @@ * lib/configFetchers.ts, lib/store.ts, lib/utils.ts */ -import { useState, useEffect, useCallback } from 'react'; -import { ConfigPublic, ConfigVersionItems, ConfigVersionResponse } from '../lib/configTypes'; -import { SavedConfig, ConfigGroup, ConfigCache } from '../lib/types/configs'; -import { CACHE_MAX_AGE_MS, CACHE_INVALIDATED_EVENT, PAGE_SIZE } from '../lib/constants'; +import { useState, useEffect, useCallback } from "react"; +import { + ConfigPublic, + ConfigVersionItems, + ConfigVersionResponse, +} from "@/app/lib/configTypes"; +import { SavedConfig, ConfigGroup, ConfigCache } from "@/app/lib/types/configs"; +import { + CACHE_MAX_AGE_MS, + CACHE_INVALIDATED_EVENT, + PAGE_SIZE, +} from "@/app/lib/constants"; import { configState, pendingVersionLoads, pendingSingleVersionLoads, loadCache, saveCache, -} from '../lib/store/configStore'; +} from "@/app/lib/store/configStore"; import { fetchAllConfigs, fetchNextConfigBatch, scheduleBackgroundValidation, -} from '../lib/configFetchers'; -import { getApiKey, flattenConfigVersion, groupConfigs } from '../lib/utils'; +} from "@/app/lib/configFetchers"; +import { flattenConfigVersion, groupConfigs } from "@/app/lib/utils"; +import { apiFetch } from "@/app/lib/apiClient"; +import { useAuth } from "@/app/lib/context/AuthContext"; export interface UseConfigsResult { configs: SavedConfig[]; @@ -55,7 +65,10 @@ export interface UseConfigsResult { * Returns the SavedConfig immediately if already loaded; makes 1 GET call otherwise. * Safe to call concurrently – duplicate in-flight requests are coalesced. */ - loadSingleVersion: (config_id: string, version: number) => Promise; + loadSingleVersion: ( + config_id: string, + version: number, + ) => Promise; /** Lightweight version items per config, indexed by config_id. */ versionItemsMap: Record; allConfigMeta: ConfigPublic[]; // Full lightweight config list from GET /api/configs. @@ -64,132 +77,164 @@ export interface UseConfigsResult { export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { const pageSize = options?.pageSize; const [configs, setConfigs] = useState([]); - const [versionCounts, setVersionCounts] = useState>({}); - const [versionItemsMap, setVersionItemsMap] = useState>({}); + const [versionCounts, setVersionCounts] = useState>( + {}, + ); + const [versionItemsMap, setVersionItemsMap] = useState< + Record + >({}); const [allConfigMeta, setAllConfigMeta] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); const [isCached, setIsCached] = useState(false); const [totalKnownCount, setTotalKnownCount] = useState(0); + const { activeKey, isHydrated } = useAuth(); + const apiKey = activeKey?.key; + + const fetchConfigs = useCallback( + async (force: boolean = false) => { + // Wait for AuthContext to load apiKey from localStorage to avoid premature "No API key" error on refresh. + if (!isHydrated) return; + + if (!apiKey) { + setError("No API key found. Please add an API key in the Keystore."); + setIsLoading(false); + return; + } - const fetchConfigs = useCallback(async (force: boolean = false) => { - const apiKey = getApiKey(); - if (!apiKey) { - setError('No API key found. Please add an API key in the Keystore.'); - setIsLoading(false); - return; - } + // ── Fast paths (skipped when force=true) ── + if (!force) { + // In-memory cache (fastest — no I/O) + if (configState.inMemoryCache) { + const cacheAge = Date.now() - configState.inMemoryCache.cachedAt; + if (cacheAge < CACHE_MAX_AGE_MS) { + // A cache saved with pageSize:0 (ConfigSelector) has configs:[] — don't let it + // satisfy a caller that needs actual config data (e.g. Library with pageSize:10). + const cachedCount = configState.inMemoryCache.configs.length; + const cacheUsable = + !configState.inMemoryCache.partialFetch || + (pageSize !== undefined && cachedCount >= pageSize); + const resolvedMeta = + configState.allConfigMeta ?? + configState.inMemoryCache.allConfigMeta ?? + null; + const totalCount = configState.inMemoryCache.totalConfigCount ?? 0; + // Skip cache if allConfigMeta is missing but configs exist (stale/old cache schema). + const cacheHasUsableMeta = + resolvedMeta !== null || totalCount === 0; + if (cacheUsable && cacheHasUsableMeta) { + configState.allConfigMeta = resolvedMeta; + setConfigs(configState.inMemoryCache.configs); + setVersionCounts(configState.inMemoryCache.versionCounts || {}); + setVersionItemsMap({ ...configState.versionItemsCache }); + setTotalKnownCount( + configState.inMemoryCache.totalConfigCount ?? + configState.inMemoryCache.configs.length, + ); + setAllConfigMeta(resolvedMeta ?? []); + setIsCached(true); + setIsLoading(false); + scheduleBackgroundValidation(configState.inMemoryCache, apiKey); + return; + } + } + } - // ── Fast paths (skipped when force=true) ── - if (!force) { - // In-memory cache (fastest — no I/O) - if (configState.inMemoryCache) { - const cacheAge = Date.now() - configState.inMemoryCache.cachedAt; - if (cacheAge < CACHE_MAX_AGE_MS) { - const cacheUsable = !configState.inMemoryCache.partialFetch || pageSize !== undefined; - const resolvedMeta = configState.allConfigMeta ?? configState.inMemoryCache.allConfigMeta ?? null; - const totalCount = configState.inMemoryCache.totalConfigCount ?? 0; - // Skip cache if allConfigMeta is missing but configs exist (stale/old cache schema). - const cacheHasUsableMeta = resolvedMeta !== null || totalCount === 0; - if (cacheUsable && cacheHasUsableMeta) { - configState.allConfigMeta = resolvedMeta; - setConfigs(configState.inMemoryCache.configs); - setVersionCounts(configState.inMemoryCache.versionCounts || {}); - setVersionItemsMap({ ...configState.versionItemsCache }); - setTotalKnownCount( - configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length, - ); - setAllConfigMeta(resolvedMeta ?? []); - setIsCached(true); - setIsLoading(false); - scheduleBackgroundValidation(configState.inMemoryCache, apiKey); - return; + // localStorage cache + const lsCache = loadCache(); + if (lsCache) { + const cacheAge = Date.now() - lsCache.cachedAt; + if (cacheAge < CACHE_MAX_AGE_MS) { + // Ignore cache with empty configs (pageSize:0) when actual data (e.g. pageSize:10) is required. + const cachedCount = lsCache.configs.length; + const cacheUsable = + !lsCache.partialFetch || + (pageSize !== undefined && cachedCount >= pageSize); + const resolvedMeta = + configState.allConfigMeta ?? lsCache.allConfigMeta ?? null; + const totalCount = lsCache.totalConfigCount ?? 0; + const cacheHasUsableMeta = + resolvedMeta !== null || totalCount === 0; + if (cacheUsable && cacheHasUsableMeta) { + configState.allConfigMeta = resolvedMeta; + configState.inMemoryCache = lsCache; + setConfigs(lsCache.configs); + setVersionCounts(lsCache.versionCounts || {}); + // versionItemsCache may be empty on cold start; loadVersionsForConfig will + // populate it on demand (1 GET /versions call) when a config is opened. + setVersionItemsMap({ ...configState.versionItemsCache }); + setTotalKnownCount( + lsCache.totalConfigCount ?? lsCache.configs.length, + ); + setAllConfigMeta(resolvedMeta ?? []); + setIsCached(true); + setIsLoading(false); + scheduleBackgroundValidation(lsCache, apiKey); + return; + } } } } - // localStorage cache - const lsCache = loadCache(); - if (lsCache) { - const cacheAge = Date.now() - lsCache.cachedAt; - if (cacheAge < CACHE_MAX_AGE_MS) { - const cacheUsable = !lsCache.partialFetch || pageSize !== undefined; - const resolvedMeta = configState.allConfigMeta ?? lsCache.allConfigMeta ?? null; - const totalCount = lsCache.totalConfigCount ?? 0; - const cacheHasUsableMeta = resolvedMeta !== null || totalCount === 0; - if (cacheUsable && cacheHasUsableMeta) { - configState.allConfigMeta = resolvedMeta; - configState.inMemoryCache = lsCache; - setConfigs(lsCache.configs); - setVersionCounts(lsCache.versionCounts || {}); - // versionItemsCache may be empty on cold start; loadVersionsForConfig will - // populate it on demand (1 GET /versions call) when a config is opened. - setVersionItemsMap({ ...configState.versionItemsCache }); - setTotalKnownCount(lsCache.totalConfigCount ?? lsCache.configs.length); - setAllConfigMeta(resolvedMeta ?? []); - setIsCached(true); - setIsLoading(false); - scheduleBackgroundValidation(lsCache, apiKey); - return; - } + if (configState.pendingFetch) { + setIsLoading(true); + await configState.pendingFetch.catch(() => { + /* error handled by originator */ + }); + if (configState.inMemoryCache) { + setConfigs(configState.inMemoryCache.configs); + setVersionCounts(configState.inMemoryCache.versionCounts || {}); + setVersionItemsMap({ ...configState.versionItemsCache }); + setTotalKnownCount( + configState.inMemoryCache.totalConfigCount ?? + configState.inMemoryCache.configs.length, + ); + setAllConfigMeta(configState.allConfigMeta ?? []); + setIsCached(false); + } else { + setError("Failed to load configurations. Please try again."); } + setIsLoading(false); + return; } - } - if (configState.pendingFetch) { setIsLoading(true); - await configState.pendingFetch.catch(() => { /* error handled by originator */ }); - if (configState.inMemoryCache) { - setConfigs(configState.inMemoryCache.configs); - setVersionCounts(configState.inMemoryCache.versionCounts || {}); + setError(null); + setIsCached(false); + + configState.pendingFetch = (async () => { + const result = await fetchAllConfigs(apiKey, pageSize); + const newCache: ConfigCache = { + configs: result.configs, + configMeta: result.configMeta, + cachedAt: Date.now(), + versionCounts: result.versionCounts, + totalConfigCount: result.totalConfigCount, + partialFetch: result.partialFetch, + allConfigMeta: configState.allConfigMeta ?? [], + }; + saveCache(newCache); + configState.inMemoryCache = newCache; + setConfigs(result.configs); + setVersionCounts(result.versionCounts); setVersionItemsMap({ ...configState.versionItemsCache }); - setTotalKnownCount( - configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length, - ); + setTotalKnownCount(result.totalConfigCount); setAllConfigMeta(configState.allConfigMeta ?? []); - setIsCached(false); - } else { - setError('Failed to load configurations. Please try again.'); - } - setIsLoading(false); - return; - } - - setIsLoading(true); - setError(null); - setIsCached(false); - - configState.pendingFetch = (async () => { - const result = await fetchAllConfigs(apiKey, pageSize); - const newCache: ConfigCache = { - configs: result.configs, - configMeta: result.configMeta, - cachedAt: Date.now(), - versionCounts: result.versionCounts, - totalConfigCount: result.totalConfigCount, - partialFetch: result.partialFetch, - allConfigMeta: configState.allConfigMeta ?? [], - }; - saveCache(newCache); - configState.inMemoryCache = newCache; - setConfigs(result.configs); - setVersionCounts(result.versionCounts); - setVersionItemsMap({ ...configState.versionItemsCache }); - setTotalKnownCount(result.totalConfigCount); - setAllConfigMeta(configState.allConfigMeta ?? []); - })().finally(() => { - configState.pendingFetch = null; - }); + })().finally(() => { + configState.pendingFetch = null; + }); - try { - await configState.pendingFetch; - } catch { - setError('Failed to load configurations. Please try again.'); - } finally { - setIsLoading(false); - } - }, [pageSize]); + try { + await configState.pendingFetch; + } catch { + setError("Failed to load configurations. Please try again."); + } finally { + setIsLoading(false); + } + }, + [pageSize, apiKey, isHydrated], + ); /** * Ensures the lightweight version list (ConfigVersionItems, no config_blob) is @@ -198,44 +243,57 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { * Cost: 0 network calls when already cached, otherwise exactly 1 GET /versions. * Does NOT fetch full version details — use loadSingleVersion for that. */ - const loadVersionsForConfig = useCallback(async (config_id: string) => { - if (configState.versionItemsCache[config_id]) { - setVersionItemsMap(prev => - prev[config_id] ? prev : { ...prev, [config_id]: configState.versionItemsCache[config_id] }, - ); - return; - } - - const existing = pendingVersionLoads.get(config_id); - if (existing) { - await existing; - return; - } + const loadVersionsForConfig = useCallback( + async (config_id: string) => { + if (configState.versionItemsCache[config_id]) { + setVersionItemsMap((prev) => + prev[config_id] + ? prev + : { + ...prev, + [config_id]: configState.versionItemsCache[config_id], + }, + ); + return; + } - const apiKey = getApiKey(); - if (!apiKey) return; + const existing = pendingVersionLoads.get(config_id); + if (existing) { + await existing; + return; + } - const loadPromise = (async () => { - const versionsResponse = await fetch(`/api/configs/${config_id}/versions`, { - headers: { 'X-API-KEY': apiKey }, + if (!apiKey) return; + + const loadPromise = (async () => { + const versionsData = await apiFetch<{ + success: boolean; + data: ConfigVersionItems[]; + }>(`/api/configs/${config_id}/versions`, apiKey); + if (!versionsData.success || !versionsData.data) return; + + configState.versionItemsCache[config_id] = versionsData.data; + setVersionItemsMap((prev) => ({ + ...prev, + [config_id]: versionsData.data, + })); + setVersionCounts((prev) => ({ + ...prev, + [config_id]: versionsData.data.length, + })); + })().finally(() => { + pendingVersionLoads.delete(config_id); }); - const versionsData = await versionsResponse.json(); - if (!versionsData.success || !versionsData.data) return; - configState.versionItemsCache[config_id] = versionsData.data; - setVersionItemsMap(prev => ({ ...prev, [config_id]: versionsData.data })); - setVersionCounts(prev => ({ ...prev, [config_id]: versionsData.data.length })); - })().finally(() => { - pendingVersionLoads.delete(config_id); - }); - - pendingVersionLoads.set(config_id, loadPromise); - try { - await loadPromise; - } catch { - console.error(`Failed to load version list for config ${config_id}`); - } - }, []); + pendingVersionLoads.set(config_id, loadPromise); + try { + await loadPromise; + } catch { + console.error(`Failed to load version list for config ${config_id}`); + } + }, + [apiKey], + ); /** * Fetches the full details (config_blob) for a single version on demand. @@ -243,76 +301,102 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { * Otherwise makes exactly 1 GET /versions/{version} call. * Concurrent calls for the same config_id:version share one in-flight request. */ - const loadSingleVersion = useCallback(async ( - config_id: string, - version: number, - ): Promise => { - const loaded = configs.find(c => c.config_id === config_id && c.version === version); - if (loaded) return loaded; - - const key = `${config_id}:${version}`; - const existing = pendingSingleVersionLoads.get(key); - if (existing) return existing; - - const apiKey = getApiKey(); - if (!apiKey) return null; - - const configSource = configs.find(c => c.config_id === config_id); - // Fall back to the lightweight allConfigMeta when the config hasn't been detail-fetched yet - const metaSource = configState.allConfigMeta?.find(m => m.id === config_id); - if (!configSource && !metaSource) return null; - - const configPublic: ConfigPublic = configSource - ? { id: config_id, name: configSource.name, description: configSource.description ?? null, project_id: 0, inserted_at: '', updated_at: '' } - : metaSource!; + const loadSingleVersion = useCallback( + async (config_id: string, version: number): Promise => { + const loaded = configs.find( + (c) => c.config_id === config_id && c.version === version, + ); + if (loaded) return loaded; - const loadPromise: Promise = (async () => { - try { - const versionResponse = await fetch( - `/api/configs/${config_id}/versions/${version}`, - { headers: { 'X-API-KEY': apiKey } }, - ); - const versionData: ConfigVersionResponse = await versionResponse.json(); - if (!versionData.success || !versionData.data) return null; + const key = `${config_id}:${version}`; + const existing = pendingSingleVersionLoads.get(key); + if (existing) return existing; - const savedConfig = flattenConfigVersion(configPublic, versionData.data); + if (!apiKey) return null; - setConfigs(prev => { - if (prev.some(c => c.config_id === config_id && c.version === version)) return prev; - const updated = [...prev, savedConfig]; - if (configState.inMemoryCache) { - configState.inMemoryCache = { ...configState.inMemoryCache, configs: updated }; - saveCache(configState.inMemoryCache); + const configSource = configs.find((c) => c.config_id === config_id); + // Fall back to the lightweight allConfigMeta when the config hasn't been detail-fetched yet + const metaSource = configState.allConfigMeta?.find( + (m) => m.id === config_id, + ); + if (!configSource && !metaSource) return null; + + const configPublic: ConfigPublic = configSource + ? { + id: config_id, + name: configSource.name, + description: configSource.description ?? null, + project_id: 0, + inserted_at: "", + updated_at: "", } - return updated; - }); - - return savedConfig; - } catch (e) { - console.error(`Failed to fetch version ${version} for config ${config_id}:`, e); - return null; - } - })().finally(() => { - pendingSingleVersionLoads.delete(key); - }); + : metaSource!; + + const loadPromise: Promise = (async () => { + try { + const versionData = await apiFetch( + `/api/configs/${config_id}/versions/${version}`, + apiKey, + ); + if (!versionData.success || !versionData.data) return null; + + const savedConfig = flattenConfigVersion( + configPublic, + versionData.data, + ); + + setConfigs((prev) => { + if ( + prev.some( + (c) => c.config_id === config_id && c.version === version, + ) + ) + return prev; + const updated = [...prev, savedConfig]; + if (configState.inMemoryCache) { + configState.inMemoryCache = { + ...configState.inMemoryCache, + configs: updated, + }; + saveCache(configState.inMemoryCache); + } + return updated; + }); + + return savedConfig; + } catch (e) { + console.error( + `Failed to fetch version ${version} for config ${config_id}:`, + e, + ); + return null; + } + })().finally(() => { + pendingSingleVersionLoads.delete(key); + }); - pendingSingleVersionLoads.set(key, loadPromise); - return loadPromise; - }, [configs]); + pendingSingleVersionLoads.set(key, loadPromise); + return loadPromise; + }, + [configs], + ); /** * Loads the next batch of configs (version list + latest detail) for configs * not yet represented in the loaded set. Used by the Config Library Load More button. */ const loadMoreConfigs = useCallback(async () => { - if (!configState.allConfigMeta || configState.allConfigMeta.length === 0) return; - const apiKey = getApiKey(); + if (!configState.allConfigMeta || configState.allConfigMeta.length === 0) + return; + const apiKey = activeKey?.key; if (!apiKey) return; const loadedIds = new Set( - (configState.inMemoryCache?.configs ?? configs).map(c => c.config_id), + (configState.inMemoryCache?.configs ?? configs).map((c) => c.config_id), + ); + const remaining = configState.allConfigMeta.filter( + (c) => !loadedIds.has(c.id), ); - const remaining = configState.allConfigMeta.filter(c => !loadedIds.has(c.id)); if (remaining.length === 0) return; if (configState.pendingLoadMore) { @@ -323,29 +407,34 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { setIsLoadingMore(true); configState.pendingLoadMore = (async () => { - const { newVersions, newVersionCounts, newConfigMeta } = await fetchNextConfigBatch( - apiKey, - loadedIds, - pageSize ?? PAGE_SIZE, - ); + const { newVersions, newVersionCounts, newConfigMeta } = + await fetchNextConfigBatch(apiKey, loadedIds, pageSize ?? PAGE_SIZE); - setConfigs(prev => { + setConfigs((prev) => { const merged = [...prev, ...newVersions]; if (configState.inMemoryCache) { - const mergedIds = new Set(merged.map(c => c.config_id)); - const stillPartial = configState.allConfigMeta!.some(c => !mergedIds.has(c.id)); + const mergedIds = new Set(merged.map((c) => c.config_id)); + const stillPartial = configState.allConfigMeta!.some( + (c) => !mergedIds.has(c.id), + ); configState.inMemoryCache = { ...configState.inMemoryCache, configs: merged, - versionCounts: { ...configState.inMemoryCache.versionCounts, ...newVersionCounts }, - configMeta: { ...configState.inMemoryCache.configMeta, ...newConfigMeta }, + versionCounts: { + ...configState.inMemoryCache.versionCounts, + ...newVersionCounts, + }, + configMeta: { + ...configState.inMemoryCache.configMeta, + ...newConfigMeta, + }, partialFetch: stillPartial, }; saveCache(configState.inMemoryCache); } return merged; }); - setVersionCounts(prev => ({ ...prev, ...newVersionCounts })); + setVersionCounts((prev) => ({ ...prev, ...newVersionCounts })); })().finally(() => { configState.pendingLoadMore = null; setIsLoadingMore(false); @@ -354,7 +443,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { try { await configState.pendingLoadMore; } catch { - console.error('Failed to load more configs'); + console.error("Failed to load more configs"); setIsLoadingMore(false); } }, [configs, pageSize]); @@ -364,14 +453,15 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult { }, [fetchConfigs]); useEffect(() => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; const handler = () => fetchConfigs(true); window.addEventListener(CACHE_INVALIDATED_EVENT, handler); return () => window.removeEventListener(CACHE_INVALIDATED_EVENT, handler); }, [fetchConfigs]); - const loadedConfigIds = new Set(configs.map(c => c.config_id)); - const hasMoreConfigs = totalKnownCount > 0 && loadedConfigIds.size < totalKnownCount; + const loadedConfigIds = new Set(configs.map((c) => c.config_id)); + const hasMoreConfigs = + totalKnownCount > 0 && loadedConfigIds.size < totalKnownCount; return { configs, 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 d6aafc12..f7cabc78 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -14,6 +14,7 @@ const STORAGE_KEY = "kaapi_api_keys"; interface AuthContextValue { apiKeys: APIKey[]; activeKey: APIKey | null; + isHydrated: boolean; addKey: (key: APIKey) => void; removeKey: (id: string) => void; setKeys: (keys: APIKey[]) => void; @@ -23,6 +24,7 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [apiKeys, setApiKeys] = useState([]); + const [isHydrated, setIsHydrated] = useState(false); // Initialize from localStorage after hydration to avoid SSR mismatch. // setState in effect is intentional here — this is a one-time external storage read. @@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch { /* ignore malformed data */ } + setIsHydrated(true); }, []); const persist = useCallback((keys: APIKey[]) => { @@ -60,6 +63,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { value={{ apiKeys, activeKey: apiKeys[0] ?? null, + isHydrated, addKey, removeKey, setKeys, 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 */} -
-
- - -
-
- -