"use client"; import { useEffect, useMemo, useState } from "react"; import { apiFetch } from "@/lib/api"; export type InferenceEngineStatus = { id: string; label: string; base_url: string; live: boolean; detail: string; available_models: string[]; }; export type TranscriptionModelOption = { id: string; name: string; repo_id: string; description: string; languages: string; available: boolean; local_path?: string | null; }; export type TranscriptionBackend = { id: string; label: string; supported: boolean; live: boolean; detail: string; models: TranscriptionModelOption[]; default_model?: string | null; }; export type TranscriptionSettings = { default_backend: string; backends: TranscriptionBackend[]; text_inference_engines: InferenceEngineStatus[]; }; const LEGACY_AUDIO_MODEL_STORAGE_KEY = "preferred_transcription_model"; const AUDIO_BACKEND_STORAGE_KEY = "preferred_transcription_backend"; const AUDIO_MODELS_STORAGE_KEY = "preferred_transcription_models"; const TEXT_ENGINE_STORAGE_KEY = "preferred_text_inference_engine"; const TEXT_URL_STORAGE_KEY = "preferred_text_inference_urls"; const getStoredValue = (key: string) => { if (typeof window === "undefined") return null; return window.localStorage.getItem(key); }; const setStoredValue = (key: string, value: string) => { if (typeof window === "undefined") return; window.localStorage.setItem(key, value); }; const getStoredUrls = () => { const raw = getStoredValue(TEXT_URL_STORAGE_KEY); if (!raw) return {} as Record; try { return JSON.parse(raw) as Record; } catch { return {}; } }; const setStoredUrls = (urls: Record) => { setStoredValue(TEXT_URL_STORAGE_KEY, JSON.stringify(urls)); }; const getStoredAudioModels = () => { const raw = getStoredValue(AUDIO_MODELS_STORAGE_KEY); if (!raw) return {} as Record; try { return JSON.parse(raw) as Record; } catch { return {}; } }; export const migrateLegacyAudioModelSelections = ( currentSelections: Record, legacyValue: string | null ) => { if (!legacyValue || currentSelections.server) return currentSelections; return { ...currentSelections, server: legacyValue }; }; const setStoredAudioModels = (models: Record) => { setStoredValue(AUDIO_MODELS_STORAGE_KEY, JSON.stringify(models)); }; const persistAudioModels = (models: Record) => { setStoredAudioModels(models); if (typeof window !== "undefined") { window.localStorage.removeItem(LEGACY_AUDIO_MODEL_STORAGE_KEY); } }; const normalizeAudioModelSelections = () => { const currentSelections = getStoredAudioModels(); const legacyValue = getStoredValue(LEGACY_AUDIO_MODEL_STORAGE_KEY); const migratedSelections = migrateLegacyAudioModelSelections(currentSelections, legacyValue); if (legacyValue && migratedSelections !== currentSelections) { persistAudioModels(migratedSelections); } return migratedSelections; }; const findAudioModel = (backend: TranscriptionBackend | undefined, modelId?: string | null) => { if (!backend || !modelId) return undefined; return backend.models.find((model) => model.id === modelId || model.repo_id === modelId); }; export const resolvePreferredAudioBackend = (settings: TranscriptionSettings, storedBackend: string | null) => { if (storedBackend && settings.backends.some((backend) => backend.id === storedBackend && backend.supported)) { return storedBackend; } if (settings.backends.some((backend) => backend.id === settings.default_backend && backend.supported)) { return settings.default_backend; } return settings.backends.find((backend) => backend.supported)?.id || settings.backends[0]?.id || "server"; }; export const resolvePreferredAudioModel = ( backend: TranscriptionBackend | undefined, storedSelections: Record ) => { if (!backend) return ""; const storedModel = findAudioModel(backend, storedSelections[backend.id]); if (storedModel) { return storedModel.id; } const defaultModel = findAudioModel(backend, backend.default_model); if (defaultModel?.available) { return defaultModel.id; } const firstAvailableModel = backend.models.find((model) => model.available); if (firstAvailableModel) { return firstAvailableModel.id; } if (defaultModel) { return defaultModel.id; } return backend.models[0]?.id || ""; }; const resolvePreferredTextEngine = (settings: TranscriptionSettings) => { const storedEngine = getStoredValue(TEXT_ENGINE_STORAGE_KEY); if (storedEngine && settings.text_inference_engines.some((engine) => engine.id === storedEngine)) { return storedEngine; } return settings.text_inference_engines[0]?.id || ""; }; export const useTranscriptionModel = () => { const [settings, setSettings] = useState(null); const [selectedAudioBackend, setSelectedAudioBackendState] = useState(""); const [selectedAudioModel, setSelectedAudioModelState] = useState(""); const [audioModelSelections, setAudioModelSelections] = useState>({}); const [selectedTextEngine, setSelectedTextEngineState] = useState(""); const [textEngineUrls, setTextEngineUrls] = useState>({}); const [textEngineStatuses, setTextEngineStatuses] = useState>({}); const [loadingTextEngineId, setLoadingTextEngineId] = useState(null); const [loadingAudioModelId, setLoadingAudioModelId] = useState(null); const [error, setError] = useState(null); useEffect(() => { let active = true; const load = async () => { try { const data = await apiFetch("/settings/transcription"); if (!active) return; const preferredAudioBackend = resolvePreferredAudioBackend(data, getStoredValue(AUDIO_BACKEND_STORAGE_KEY)); const storedAudioSelections = normalizeAudioModelSelections(); const selectedBackend = data.backends.find((backend) => backend.id === preferredAudioBackend); const preferredAudioModel = resolvePreferredAudioModel(selectedBackend, storedAudioSelections); const nextAudioSelections = preferredAudioModel ? { ...storedAudioSelections, [preferredAudioBackend]: preferredAudioModel } : storedAudioSelections; const preferredTextEngine = resolvePreferredTextEngine(data); const storedUrls = getStoredUrls(); const nextStatuses = data.text_inference_engines.reduce>((acc, engine) => { acc[engine.id] = engine; return acc; }, {}); const nextUrls = data.text_inference_engines.reduce>((acc, engine) => { acc[engine.id] = storedUrls[engine.id] || engine.base_url; return acc; }, {}); setSettings(data); setSelectedAudioBackendState(preferredAudioBackend); setSelectedAudioModelState(preferredAudioModel); setAudioModelSelections(nextAudioSelections); setSelectedTextEngineState(preferredTextEngine); setTextEngineStatuses(nextStatuses); setTextEngineUrls(nextUrls); setStoredValue(AUDIO_BACKEND_STORAGE_KEY, preferredAudioBackend); persistAudioModels(nextAudioSelections); setStoredValue(TEXT_ENGINE_STORAGE_KEY, preferredTextEngine); setStoredUrls(nextUrls); setError(null); } catch (loadError) { if (!active) return; setError(loadError instanceof Error ? loadError.message : "Unable to load transcription settings."); } }; load(); return () => { active = false; }; }, []); const setSelectedAudioBackend = (backendId: string) => { setSelectedAudioBackendState(backendId); setStoredValue(AUDIO_BACKEND_STORAGE_KEY, backendId); const backend = settings?.backends.find((entry) => entry.id === backendId); const nextModel = resolvePreferredAudioModel(backend, audioModelSelections); setSelectedAudioModelState(nextModel); if (backend && nextModel) { const nextSelections = { ...audioModelSelections, [backend.id]: nextModel }; setAudioModelSelections(nextSelections); persistAudioModels(nextSelections); } }; const setSelectedAudioModel = (modelId: string) => { setSelectedAudioModelState(modelId); if (!selectedAudioBackend) return; const nextSelections = { ...audioModelSelections, [selectedAudioBackend]: modelId }; setAudioModelSelections(nextSelections); persistAudioModels(nextSelections); }; const setSelectedTextEngine = (engineId: string) => { setSelectedTextEngineState(engineId); setStoredValue(TEXT_ENGINE_STORAGE_KEY, engineId); }; const setTextEngineUrl = (engineId: string, baseUrl: string) => { setTextEngineUrls((current) => { const next = { ...current, [engineId]: baseUrl }; setStoredUrls(next); return next; }); }; const updateDownloadedAudioModel = (backendId: string, downloadedModel: TranscriptionModelOption) => { setSettings((current) => { if (!current) return current; return { ...current, backends: current.backends.map((backend) => { if (backend.id !== backendId) return backend; const alreadyListed = backend.models.some((model) => model.id === downloadedModel.id); return { ...backend, models: alreadyListed ? backend.models.map((model) => (model.id === downloadedModel.id ? downloadedModel : model)) : [...backend.models, downloadedModel], }; }), }; }); }; const connectTextEngine = async (engineId: string, baseUrl?: string) => { const currentBaseUrl = baseUrl || textEngineUrls[engineId] || ""; setLoadingTextEngineId(engineId); setError(null); try { const status = await apiFetch("/settings/local-inference/check", { method: "POST", body: JSON.stringify({ engine_id: engineId, base_url: currentBaseUrl, }), }); setTextEngineStatuses((current) => ({ ...current, [engineId]: status })); setTextEngineUrl(engineId, status.base_url); } catch (connectError) { setError(connectError instanceof Error ? connectError.message : "Unable to connect to the text engine."); } finally { setLoadingTextEngineId(null); } }; const downloadAudioModel = async (modelId: string) => { if (!selectedAudioBackend) return; setLoadingAudioModelId(modelId); setError(null); try { const downloadedModel = await apiFetch("/settings/transcription/models/download", { method: "POST", body: JSON.stringify({ backend: selectedAudioBackend, model: modelId, }), }); updateDownloadedAudioModel(selectedAudioBackend, downloadedModel); setSelectedAudioModelState(downloadedModel.id); const nextSelections = { ...audioModelSelections, [selectedAudioBackend]: downloadedModel.id }; setAudioModelSelections(nextSelections); persistAudioModels(nextSelections); } catch (downloadError) { setError(downloadError instanceof Error ? downloadError.message : "Unable to download the selected model."); } finally { setLoadingAudioModelId(null); } }; const selectedAudioBackendStatus = useMemo( () => settings?.backends.find((backend) => backend.id === selectedAudioBackend), [selectedAudioBackend, settings] ); const selectedAudioModelOption = useMemo( () => selectedAudioBackendStatus?.models.find((model) => model.id === selectedAudioModel), [selectedAudioBackendStatus, selectedAudioModel] ); const selectedTextEngineStatus = selectedTextEngine ? textEngineStatuses[selectedTextEngine] : undefined; useEffect(() => { if (!selectedAudioBackendStatus) return; const nextModel = resolvePreferredAudioModel(selectedAudioBackendStatus, audioModelSelections); if (nextModel && nextModel !== selectedAudioModel) { setSelectedAudioModelState(nextModel); } if (nextModel && audioModelSelections[selectedAudioBackendStatus.id] !== nextModel) { const nextSelections = { ...audioModelSelections, [selectedAudioBackendStatus.id]: nextModel }; setAudioModelSelections(nextSelections); persistAudioModels(nextSelections); } }, [audioModelSelections, selectedAudioBackendStatus, selectedAudioModel]); return { audioBackends: settings?.backends || [], defaultAudioBackend: settings?.default_backend || "", selectedAudioBackend, setSelectedAudioBackend, selectedAudioBackendStatus, audioModelOptions: selectedAudioBackendStatus?.models || [], selectedAudioModel, selectedAudioModelOption, setSelectedAudioModel, downloadAudioModel, loadingAudioModelId, effectiveSelection: selectedAudioBackend && selectedAudioModel ? { backend: selectedAudioBackend, model: selectedAudioModel } : undefined, textInferenceEngines: settings?.text_inference_engines || [], textEngineStatuses, selectedTextEngine, setSelectedTextEngine, textEngineUrls, setTextEngineUrl, connectTextEngine, loadingTextEngineId, selectedTextEngineStatus, error, }; }; export type UseTranscriptionModelResult = ReturnType;