/** * Model selection state for the inspection pipeline. * * The header dropdown lets a demo user switch between the upstream * pre-trained YOLO weights and the custom weights we fine-tuned on * Turkish-market vehicles. The chosen model id is: * * 1. persisted in localStorage so it survives a hard reload, and * 2. injected as `?model=` on every `POST /api/v1/inspect` call. * * Backend exposes `GET /api/v1/models` to enumerate the available models. * If that endpoint is not yet live (parallel backend work), we fall back * to a hardcoded list so the UI still works in dev. */ import axios from 'axios'; import { API_BASE_URL, getStoredAccessToken } from './api'; export type ModelKind = 'pretrained' | 'custom'; export interface ModelOption { id: string; label: string; kind: ModelKind; /** Optional secondary line (e.g. "YOLOv8-seg · 80 sınıf"). */ description?: string; /** True when this is the recommended default. */ recommended?: boolean; } const STORAGE_KEY = 'selected_model'; /** * Hardcoded fallback used when `/api/v1/models` is unreachable. The first * entry of `kind: 'custom'` is the documented MVP default and gets * `recommended: true` so the UI badges it. */ // IMPORTANT: id'ler backend kontratiyla esleşmek zorunda. Backend // services/backend/ml_service.py DEFAULT_MODEL_ID="custom" ve registry // "pretrained_ultralytics_yolo11m" / "pretrained_roboflow_cardd" id'lerini // taniyor. Yanlis id 400 "Bilinmeyen model" donduruyor → frontend // "sunucu hatasi" gosteriyor. export const FALLBACK_MODELS: ModelOption[] = [ { id: 'custom', label: 'Kendi Modelim', kind: 'custom', description: 'YOLO11-seg · Bizim 3-model pipeline (damage + parts + severity)', recommended: true, }, { id: 'pretrained_ultralytics_yolo11m', label: 'Pre-trained: Ultralytics', kind: 'pretrained', description: 'YOLO11m-seg · COCO 80-class baseline', }, { id: 'pretrained_roboflow_cardd', label: 'Pre-trained: Roboflow Scratch/Dent', kind: 'pretrained', description: 'Roboflow Hosted Inference — sadece bbox tespiti', }, ]; export const DEFAULT_MODEL_ID = FALLBACK_MODELS[0]!.id; // Eski sürümlerde kaydedilmiş stale id'leri yeni kontratla map et. const LEGACY_ID_MIGRATIONS: Record = { 'custom-yolo26-seg': 'custom', 'pretrained-yolo26': 'pretrained_ultralytics_yolo11m', }; function isBrowser(): boolean { return typeof window !== 'undefined'; } export function getSelectedModelId(): string { if (!isBrowser()) return DEFAULT_MODEL_ID; try { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return DEFAULT_MODEL_ID; // Migration: eski id varsa yeniyle güncelle if (LEGACY_ID_MIGRATIONS[stored]) { const newId = LEGACY_ID_MIGRATIONS[stored]; localStorage.setItem(STORAGE_KEY, newId); return newId; } return stored; } catch { return DEFAULT_MODEL_ID; } } export function setSelectedModelId(id: string): void { if (!isBrowser()) return; try { localStorage.setItem(STORAGE_KEY, id); // Broadcast to other listeners in the same tab. The native `storage` // event only fires across tabs. window.dispatchEvent(new CustomEvent('selected_model_change', { detail: id })); } catch { /* localStorage unavailable: silently degrade */ } } /** * Best-effort fetch of the available models. Never throws — on any * failure (network, 401, 404, malformed payload) we return the hardcoded * fallback so the dropdown is never empty. */ export async function fetchAvailableModels(opts: { signal?: AbortSignal; } = {}): Promise { try { const token = getStoredAccessToken(); const res = await axios.get(`${API_BASE_URL}/api/v1/models`, { timeout: 6_000, signal: opts.signal, headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); const parsed = parseModelsPayload(res.data); if (parsed.length > 0) return parsed; return FALLBACK_MODELS; } catch { return FALLBACK_MODELS; } } /** * Tolerant parser — backend hasn't pinned the response shape yet, so we * accept any of: * - { models: [...] } * - [...] * - { items: [...] } * with item shape { id, name?|label?, kind?, description?, recommended? }. */ function parseModelsPayload(data: unknown): ModelOption[] { if (!data) return []; let arr: unknown[] = []; if (Array.isArray(data)) { arr = data; } else if (typeof data === 'object') { const d = data as Record; if (Array.isArray(d.models)) arr = d.models; else if (Array.isArray(d.items)) arr = d.items; } const out: ModelOption[] = []; for (const raw of arr) { if (!raw || typeof raw !== 'object') continue; const r = raw as Record; const id = typeof r.id === 'string' ? r.id : null; if (!id) continue; const label = typeof r.label === 'string' ? r.label : typeof r.name === 'string' ? r.name : id; const kindRaw = typeof r.kind === 'string' ? r.kind.toLowerCase() : ''; const idLower = id.toLowerCase(); const kind: ModelKind = kindRaw === 'pretrained' ? 'pretrained' : kindRaw === 'custom' ? 'custom' : idLower.includes('pretrain') || idLower.includes('coco') ? 'pretrained' : 'custom'; out.push({ id, label, kind, description: typeof r.description === 'string' ? r.description : undefined, recommended: r.recommended === true, }); } return out; } /** * Subscribe to selected-model changes (both same-tab CustomEvent and * cross-tab `storage` events). Returns an unsubscribe function. */ export function subscribeSelectedModel(cb: (id: string) => void): () => void { if (!isBrowser()) return () => {}; const onCustom = (e: Event) => { const detail = (e as CustomEvent).detail; if (typeof detail === 'string') cb(detail); }; const onStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY && typeof e.newValue === 'string') { cb(e.newValue); } }; window.addEventListener('selected_model_change', onCustom); window.addEventListener('storage', onStorage); return () => { window.removeEventListener('selected_model_change', onCustom); window.removeEventListener('storage', onStorage); }; }