|
|
import { ChevronDown } from "lucide-react"; |
|
|
import { MODEL_OPTIONS } from "../constants/models"; |
|
|
import LiquidAILogo from "./icons/LiquidAILogo"; |
|
|
import HfLogo from "./icons/HfLogo"; |
|
|
import MCPLogo from "./icons/MCPLogo"; |
|
|
import { useEffect, useMemo, useRef, useState } from "react"; |
|
|
import ReactDOM from "react-dom"; |
|
|
|
|
|
type Dot = { |
|
|
x: number; |
|
|
y: number; |
|
|
radius: number; |
|
|
speed: number; |
|
|
opacity: number; |
|
|
blur: number; |
|
|
pulse: number; |
|
|
pulseSpeed: number; |
|
|
}; |
|
|
export const LoadingScreen = ({ |
|
|
isLoading, |
|
|
progress, |
|
|
error, |
|
|
loadSelectedModel, |
|
|
selectedModelId, |
|
|
isModelDropdownOpen, |
|
|
setIsModelDropdownOpen, |
|
|
handleModelSelect, |
|
|
}: { |
|
|
isLoading: boolean; |
|
|
progress: number; |
|
|
error: string | null; |
|
|
loadSelectedModel: () => void; |
|
|
selectedModelId: string; |
|
|
isModelDropdownOpen: boolean; |
|
|
setIsModelDropdownOpen: (isOpen: boolean) => void; |
|
|
handleModelSelect: (modelId: string) => void; |
|
|
}) => { |
|
|
const model = useMemo( |
|
|
() => MODEL_OPTIONS.find((opt) => opt.id === selectedModelId), |
|
|
[selectedModelId] |
|
|
); |
|
|
|
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null); |
|
|
const dropdownBtnRef = useRef<HTMLButtonElement>(null); |
|
|
const dropdownRef = useRef<HTMLDivElement>(null); |
|
|
const wrapperRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
|
const [activeIndex, setActiveIndex] = useState( |
|
|
Math.max( |
|
|
0, |
|
|
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId) |
|
|
) |
|
|
); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const canvas = canvasRef.current; |
|
|
if (!canvas) return; |
|
|
|
|
|
const ctx = canvas.getContext("2d"); |
|
|
if (!ctx) return; |
|
|
|
|
|
const prefersReduced = |
|
|
typeof window !== "undefined" && |
|
|
window.matchMedia && |
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
|
|
|
|
|
let animationFrameId: number; |
|
|
let dots: Dot[] = []; |
|
|
|
|
|
const setup = () => { |
|
|
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); |
|
|
const { innerWidth, innerHeight } = window; |
|
|
canvas.width = Math.floor(innerWidth * dpr); |
|
|
canvas.height = Math.floor(innerHeight * dpr); |
|
|
canvas.style.width = `${innerWidth}px`; |
|
|
canvas.style.height = `${innerHeight}px`; |
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
|
|
|
|
|
dots = []; |
|
|
const numDots = Math.floor((innerWidth * innerHeight) / 12000); |
|
|
for (let i = 0; i < numDots; ++i) { |
|
|
dots.push({ |
|
|
x: Math.random() * innerWidth, |
|
|
y: Math.random() * innerHeight, |
|
|
radius: Math.random() * 2 + 0.3, |
|
|
speed: prefersReduced ? 0 : Math.random() * 0.3 + 0.05, |
|
|
opacity: Math.random() * 0.4 + 0.1, |
|
|
blur: Math.random() > 0.8 ? Math.random() * 1.5 + 0.5 : 0, |
|
|
pulse: Math.random() * Math.PI * 2, |
|
|
pulseSpeed: prefersReduced ? 0 : Math.random() * 0.02 + 0.01, |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
const draw = () => { |
|
|
if (!ctx) return; |
|
|
const width = canvas.clientWidth; |
|
|
const height = canvas.clientHeight; |
|
|
ctx.clearRect(0, 0, width, height); |
|
|
|
|
|
dots.forEach((dot) => { |
|
|
dot.y += dot.speed; |
|
|
dot.pulse += dot.pulseSpeed; |
|
|
|
|
|
if (dot.y > height + dot.radius) { |
|
|
dot.y = -dot.radius; |
|
|
dot.x = Math.random() * width; |
|
|
} |
|
|
|
|
|
const pulseFactor = 1 + Math.sin(dot.pulse) * 0.2; |
|
|
const currentRadius = dot.radius * pulseFactor; |
|
|
const currentOpacity = dot.opacity * (0.8 + Math.sin(dot.pulse) * 0.2); |
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.arc(dot.x, dot.y, currentRadius, 0, Math.PI * 2); |
|
|
ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`; |
|
|
if (dot.blur > 0) ctx.filter = `blur(${dot.blur}px)`; |
|
|
ctx.fill(); |
|
|
ctx.filter = "none"; |
|
|
}); |
|
|
|
|
|
animationFrameId = requestAnimationFrame(draw); |
|
|
}; |
|
|
|
|
|
const handleResize = () => { |
|
|
cancelAnimationFrame(animationFrameId); |
|
|
setup(); |
|
|
draw(); |
|
|
}; |
|
|
|
|
|
setup(); |
|
|
draw(); |
|
|
window.addEventListener("resize", handleResize); |
|
|
|
|
|
return () => { |
|
|
window.removeEventListener("resize", handleResize); |
|
|
cancelAnimationFrame(animationFrameId); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!isModelDropdownOpen) return; |
|
|
|
|
|
const onKey = (e: KeyboardEvent) => { |
|
|
if (e.key === "Escape") setIsModelDropdownOpen(false); |
|
|
if (e.key === "ArrowDown") { |
|
|
e.preventDefault(); |
|
|
setActiveIndex((i) => Math.min(MODEL_OPTIONS.length - 1, i + 1)); |
|
|
} |
|
|
if (e.key === "ArrowUp") { |
|
|
e.preventDefault(); |
|
|
setActiveIndex((i) => Math.max(0, i - 1)); |
|
|
} |
|
|
if (e.key === "Enter") { |
|
|
e.preventDefault(); |
|
|
const opt = MODEL_OPTIONS[activeIndex]; |
|
|
if (opt) { |
|
|
handleModelSelect(opt.id); |
|
|
setIsModelDropdownOpen(false); |
|
|
dropdownBtnRef.current?.focus(); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
const onClick = (e: MouseEvent) => { |
|
|
const target = e.target as Node; |
|
|
if ( |
|
|
dropdownRef.current && |
|
|
!dropdownRef.current.contains(target) && |
|
|
!dropdownBtnRef.current?.contains(target) |
|
|
) { |
|
|
setIsModelDropdownOpen(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
document.addEventListener("keydown", onKey); |
|
|
document.addEventListener("mousedown", onClick); |
|
|
return () => { |
|
|
document.removeEventListener("keydown", onKey); |
|
|
document.removeEventListener("mousedown", onClick); |
|
|
}; |
|
|
}, [ |
|
|
isModelDropdownOpen, |
|
|
activeIndex, |
|
|
setIsModelDropdownOpen, |
|
|
handleModelSelect, |
|
|
]); |
|
|
|
|
|
|
|
|
const [, forceRerender] = useState(0); |
|
|
useEffect(() => { |
|
|
const onResize = () => forceRerender((x) => x + 1); |
|
|
window.addEventListener("resize", onResize); |
|
|
return () => window.removeEventListener("resize", onResize); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const portalStyle = useMemo(() => { |
|
|
if (typeof window === "undefined") return {}; |
|
|
const anchor = wrapperRef.current || dropdownBtnRef.current; |
|
|
if (!anchor) return {}; |
|
|
|
|
|
const rect = anchor.getBoundingClientRect(); |
|
|
|
|
|
const margin = 8; |
|
|
const minWidth = 320; |
|
|
const dropdownWidth = Math.max(rect.width, minWidth); |
|
|
|
|
|
|
|
|
let left = Math.round(rect.left + rect.width / 2 - dropdownWidth / 2); |
|
|
|
|
|
left = Math.min( |
|
|
Math.max(margin, left), |
|
|
window.innerWidth - dropdownWidth - margin |
|
|
); |
|
|
|
|
|
|
|
|
const spaceBelow = window.innerHeight - rect.bottom; |
|
|
const spaceAbove = rect.top; |
|
|
const estimatedItemH = 56; |
|
|
const estimatedPad = 16; |
|
|
const estimatedHeight = |
|
|
estimatedItemH * Math.min(MODEL_OPTIONS.length, 6) + estimatedPad; |
|
|
const dropUp = spaceBelow < estimatedHeight && spaceAbove > spaceBelow; |
|
|
|
|
|
const top = dropUp ? rect.top - estimatedHeight - 8 : rect.bottom + 8; |
|
|
|
|
|
return { |
|
|
position: "fixed" as const, |
|
|
left: `${left}px`, |
|
|
top: `${top}px`, |
|
|
width: `${dropdownWidth}px`, |
|
|
zIndex: 100, |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
return ( |
|
|
<div className="relative flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 text-white p-6 overflow-hidden"> |
|
|
{/* Background Canvas */} |
|
|
<canvas |
|
|
ref={canvasRef} |
|
|
className="absolute top-0 left-0 w-full h-full z-0" |
|
|
/> |
|
|
|
|
|
{/* Vignette Overlay */} |
|
|
<div className="absolute top-0 left-0 w-full h-full z-10 bg-[radial-gradient(ellipse_at_center,_rgba(15,23,42,0.1)_0%,_rgba(15,23,42,0.4)_40%,_rgba(15,23,42,0.9)_100%)]" /> |
|
|
|
|
|
{/* Grid Overlay */} |
|
|
<div className="absolute inset-0 z-5 opacity-[0.02] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]" /> |
|
|
|
|
|
{/* Main Content */} |
|
|
<div className="relative z-20 max-w-4xl w-full flex flex-col items-center"> |
|
|
{/* Logos */} |
|
|
<div className="flex items-center justify-center mb-8 gap-5"> |
|
|
<a |
|
|
href="https://www.liquid.ai/" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
title="Liquid AI" |
|
|
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1" |
|
|
> |
|
|
<LiquidAILogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" /> |
|
|
</a> |
|
|
<span className="text-gray-500 text-3xl font-extralight">×</span> |
|
|
<a |
|
|
href="https://huggingface.co/docs/transformers.js" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
title="Transformers.js" |
|
|
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1" |
|
|
> |
|
|
<HfLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" /> |
|
|
</a> |
|
|
<span className="text-gray-500 text-3xl font-extralight">×</span> |
|
|
<a |
|
|
href="https://modelcontextprotocol.io/" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
title="Model Context Protocol" |
|
|
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1" |
|
|
> |
|
|
<MCPLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" /> |
|
|
</a> |
|
|
</div> |
|
|
|
|
|
{/* Hero */} |
|
|
<div className="text-center mb-8 space-y-4"> |
|
|
<h1 className="text-5xl sm:text-6xl md:text-7xl font-black bg-gradient-to-r from-white via-gray-100 to-gray-300 bg-clip-text text-transparent tracking-tight leading-none"> |
|
|
LFM2 MCP |
|
|
</h1> |
|
|
<p className="text-lg sm:text-xl md:text-2xl text-gray-300 font-light leading-relaxed"> |
|
|
Run next-gen hybrid models in your browser with tools powered by the{" "} |
|
|
<a |
|
|
href="https://modelcontextprotocol.io/" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
> |
|
|
<span className="text-indigo-400 font-medium"> |
|
|
Model Context Protocol (MCP) |
|
|
</span>{" "} |
|
|
enabling secure, real-time connections to remote servers. |
|
|
</a> |
|
|
</p> |
|
|
<div className="w-24 h-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full mx-auto" /> |
|
|
</div> |
|
|
|
|
|
{/* Description Cards */} |
|
|
<div className="grid md:grid-cols-2 gap-6 text-gray-400 mb-10"> |
|
|
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10"> |
|
|
<h3 className="text-white font-semibold mb-3 flex items-center"> |
|
|
<div className="w-2 h-2 bg-indigo-500 rounded-full mr-3" /> |
|
|
Model Context Protocol |
|
|
</h3> |
|
|
<p className="text-sm leading-relaxed"> |
|
|
Connect seamlessly to remote{" "} |
|
|
<a |
|
|
href="https://modelcontextprotocol.io/" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
className="text-indigo-400 hover:underline" |
|
|
> |
|
|
MCP servers |
|
|
</a>{" "} |
|
|
using streaming or SSE protocols with support for no-auth, basic |
|
|
auth, and OAuth. |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10"> |
|
|
<h3 className="text-white font-semibold mb-3 flex items-center"> |
|
|
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3" /> |
|
|
Edge AI Technology |
|
|
</h3> |
|
|
<p className="text-sm leading-relaxed"> |
|
|
Powered by{" "} |
|
|
<a |
|
|
href="https://www.liquid.ai/" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
className="text-indigo-400 hover:underline" |
|
|
> |
|
|
Liquid AI’s |
|
|
</a>{" "} |
|
|
LFM2 hybrid models, optimized for on-device deployment and edge AI |
|
|
scenarios. |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<p className="text-gray-400 text-base sm:text-lg mb-10"> |
|
|
Everything runs entirely in your browser with{" "} |
|
|
<a |
|
|
href="https://huggingface.co/docs/transformers.js" |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
className="text-indigo-400 hover:underline font-medium" |
|
|
> |
|
|
Transformers.js |
|
|
</a>{" "} |
|
|
and ONNX Runtime Web. |
|
|
</p> |
|
|
|
|
|
{/* Action */} |
|
|
<div className="text-center space-y-6"> |
|
|
<p className="text-gray-400 text-base sm:text-lg font-medium"> |
|
|
Select a model to load locally, and connect to a remote MCP server |
|
|
to get started. |
|
|
</p> |
|
|
|
|
|
<div className="relative"> |
|
|
<div |
|
|
ref={wrapperRef} // anchor for dropdown centering |
|
|
className="flex rounded-2xl shadow-2xl overflow-hidden" |
|
|
> |
|
|
<button |
|
|
onClick={isLoading ? undefined : loadSelectedModel} |
|
|
disabled={isLoading} |
|
|
className={`flex items-center justify-center font-bold transition-all text-lg flex-1 ${ |
|
|
isLoading |
|
|
? "bg-gray-700 text-gray-400 cursor-not-allowed" |
|
|
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg hover:shadow-xl transform hover:scale-[1.01] active:scale-[0.99]" |
|
|
}`} |
|
|
aria-live="polite" |
|
|
aria-busy={isLoading} |
|
|
aria-label={ |
|
|
isLoading |
|
|
? `Loading ${model?.label ?? "model"} ${progress}%` |
|
|
: `Load ${model?.label ?? "model"}` |
|
|
} |
|
|
> |
|
|
<div className="px-8 py-4"> |
|
|
{isLoading ? ( |
|
|
<div className="flex items-center"> |
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> |
|
|
<span className="ml-3 font-semibold"> |
|
|
Loading... {progress}% |
|
|
</span> |
|
|
</div> |
|
|
) : ( |
|
|
<span className="font-semibold">Load {model?.label}</span> |
|
|
)} |
|
|
</div> |
|
|
</button> |
|
|
|
|
|
<button |
|
|
ref={dropdownBtnRef} |
|
|
onClick={(e) => { |
|
|
if (!isLoading) { |
|
|
e.stopPropagation(); |
|
|
setIsModelDropdownOpen(!isModelDropdownOpen); |
|
|
setActiveIndex( |
|
|
Math.max( |
|
|
0, |
|
|
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId) |
|
|
) |
|
|
); |
|
|
} |
|
|
}} |
|
|
onKeyDown={(e) => { |
|
|
if (isLoading) return; |
|
|
if ( |
|
|
e.key === " " || |
|
|
e.key === "Enter" || |
|
|
e.key === "ArrowDown" |
|
|
) { |
|
|
e.preventDefault(); |
|
|
if (!isModelDropdownOpen) setIsModelDropdownOpen(true); |
|
|
} |
|
|
}} |
|
|
aria-haspopup="menu" |
|
|
aria-expanded={isModelDropdownOpen} |
|
|
aria-controls="model-dropdown" |
|
|
aria-label="Select model" |
|
|
className={`px-4 py-4 border-l border-white/20 transition-all ${ |
|
|
isLoading |
|
|
? "bg-gray-700 cursor-not-allowed" |
|
|
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 hover:shadow-lg transform hover:scale-[1.01] active:scale-[0.99]" |
|
|
}`} |
|
|
disabled={isLoading} |
|
|
> |
|
|
<ChevronDown |
|
|
size={20} |
|
|
className={`transition-transform duration-200 ${ |
|
|
isModelDropdownOpen ? "rotate-180" : "" |
|
|
}`} |
|
|
/> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
{/* Dropdown (Portal) */} |
|
|
{isModelDropdownOpen && |
|
|
typeof document !== "undefined" && |
|
|
ReactDOM.createPortal( |
|
|
<div |
|
|
id="model-dropdown" |
|
|
ref={dropdownRef} |
|
|
style={portalStyle} |
|
|
role="menu" |
|
|
aria-label="Model options" |
|
|
className="bg-gray-800/95 border border-gray-600/50 rounded-2xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200 dropdown-z30" |
|
|
> |
|
|
{MODEL_OPTIONS.map((option, index) => { |
|
|
const selected = selectedModelId === option.id; |
|
|
const isActive = activeIndex === index; |
|
|
return ( |
|
|
<button |
|
|
key={option.id} |
|
|
role="menuitem" |
|
|
aria-checked={selected} |
|
|
onMouseEnter={() => setActiveIndex(index)} |
|
|
onClick={() => { |
|
|
handleModelSelect(option.id); |
|
|
setIsModelDropdownOpen(false); |
|
|
dropdownBtnRef.current?.focus(); |
|
|
}} |
|
|
className={`w-full px-6 py-4 text-left transition-all duration-200 relative group outline-none ${ |
|
|
selected |
|
|
? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400" |
|
|
: "text-gray-200 hover:bg-white/10 hover:text-white" |
|
|
} ${index === 0 ? "rounded-t-2xl" : ""} ${ |
|
|
index === MODEL_OPTIONS.length - 1 |
|
|
? "rounded-b-2xl" |
|
|
: "" |
|
|
} ${isActive && !selected ? "bg-white/5" : ""}`} |
|
|
> |
|
|
<div className="flex items-center justify-between"> |
|
|
<div> |
|
|
<div className="font-semibold text-lg"> |
|
|
{option.label} |
|
|
</div> |
|
|
<div className="text-sm text-gray-400 mt-1"> |
|
|
{option.size} |
|
|
</div> |
|
|
</div> |
|
|
{selected && ( |
|
|
<div className="w-2 h-2 bg-indigo-400 rounded-full" /> |
|
|
)} |
|
|
</div> |
|
|
{!selected && ( |
|
|
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl" /> |
|
|
)} |
|
|
</button> |
|
|
); |
|
|
})} |
|
|
</div>, |
|
|
document.body |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Error */} |
|
|
{error && ( |
|
|
<div |
|
|
role="alert" |
|
|
className="bg-red-900/30 backdrop-blur-sm border border-red-500/50 rounded-2xl p-6 mt-8 max-w-md text-center" |
|
|
> |
|
|
<p className="text-red-200 mb-4 font-medium">Error: {error}</p> |
|
|
<button |
|
|
onClick={loadSelectedModel} |
|
|
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 px-6 py-3 rounded-xl font-semibold transition-all transform hover:scale-105 active:scale-95 shadow-lg" |
|
|
> |
|
|
Try Again |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{/* Click-away fallback for touch devices */} |
|
|
{isModelDropdownOpen && ( |
|
|
<div |
|
|
className="fixed inset-0 z-40 bg-black/20" |
|
|
onClick={(e) => { |
|
|
const target = e.target as Node; |
|
|
if ( |
|
|
dropdownRef.current && |
|
|
!dropdownRef.current.contains(target) && |
|
|
!dropdownBtnRef.current?.contains(target) |
|
|
) { |
|
|
setIsModelDropdownOpen(false); |
|
|
} |
|
|
}} |
|
|
/> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|