export function cn(...parts: Array): string { return parts.filter(Boolean).join(" "); } export function uid(prefix = "id"): string { const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 8) : Math.random().toString(36).slice(2, 10); return `${prefix}_${Date.now().toString(36)}_${rand}`; } /** Deterministic-ish shuffle (Fisher-Yates). Returns a new array. */ export function shuffle(input: readonly T[]): T[] { const out = [...input]; for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out; } export function formatDate(timestamp: number): string { try { return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", year: "numeric", }).format(new Date(timestamp)); } catch { return ""; } } /** Remove ```json fences and stray prose around a JSON object. */ export function stripFences(text: string): string { let value = text.trim(); const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i); if (fence) value = fence[1].trim(); if (value.startsWith("{") || value.startsWith("[")) return value; const start = value.indexOf("{"); const end = value.lastIndexOf("}"); if (start !== -1 && end > start) return value.slice(start, end + 1); return value; } export function parseJsonLoose(text: string): unknown { const cleaned = stripFences(text); try { return JSON.parse(cleaned); } catch { // Last resort: tolerate trailing commas from chatty models. return JSON.parse(cleaned.replace(/,\s*([}\]])/g, "$1")); } } export function truncate(value: string, max: number): string { return value.length > max ? `${value.slice(0, max - 1)}…` : value; } /** * A friendlier label for a model name: "gemma4:31b-cloud" → "Gemma 4 31B", * "gpt-oss:120b" → "GPT OSS 120B". Chips show this; the raw name lives in a * tooltip (and the picker keeps the technical names). */ export function prettyModelName(name: string): string { const base = name.replace(/:cloud$/i, "").replace(/-cloud$/i, ""); const [family, ...tagParts] = base.split(":"); const words = family .replace(/[-_.]+/g, " ") .replace(/([a-z])(\d)/gi, "$1 $2") .trim() .split(/\s+/) .filter(Boolean); const prettyFamily = words .map((word) => /^[a-z]{1,3}$/i.test(word) && word === word.toLowerCase() ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1), ) .join(" "); const tag = tagParts .join(" ") .replace(/^(\d+)([a-z])$/i, (_match, size: string, unit: string) => `${size}${unit.toUpperCase()}`); return [prettyFamily, tag].filter(Boolean).join(" "); }