LiquidAI/LFM2-MCP
39
1import { ChevronDown } from "lucide-react";2import { MODEL_OPTIONS } from "../constants/models";3import LiquidAILogo from "./icons/LiquidAILogo";4import HfLogo from "./icons/HfLogo";5import MCPLogo from "./icons/MCPLogo";6import { useEffect, useMemo, useRef, useState } from "react";7import ReactDOM from "react-dom";8 9type Dot = {10 x: number;11 y: number;12 radius: number;13 speed: number;14 opacity: number;15 blur: number;16 pulse: number;17 pulseSpeed: number;18};19export const LoadingScreen = ({20 isLoading,21 progress,22 error,23 loadSelectedModel,24 selectedModelId,25 isModelDropdownOpen,26 setIsModelDropdownOpen,27 handleModelSelect,28}: {29 isLoading: boolean;30 progress: number;31 error: string | null;32 loadSelectedModel: () => void;33 selectedModelId: string;34 isModelDropdownOpen: boolean;35 setIsModelDropdownOpen: (isOpen: boolean) => void;36 handleModelSelect: (modelId: string) => void;37}) => {38 const model = useMemo(39 () => MODEL_OPTIONS.find((opt) => opt.id === selectedModelId),40 [selectedModelId]41 );42 43 // Refs44 const canvasRef = useRef<HTMLCanvasElement>(null);45 const dropdownBtnRef = useRef<HTMLButtonElement>(null);46 const dropdownRef = useRef<HTMLDivElement>(null);47 const wrapperRef = useRef<HTMLDivElement>(null); // NEW: anchor for centering48 49 // For keyboard navigation50 const [activeIndex, setActiveIndex] = useState(51 Math.max(52 0,53 MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)54 )55 );56 57 // Background Animation Effect (crisper dots + reduced motion)58 useEffect(() => {59 const canvas = canvasRef.current;60 if (!canvas) return;61 62 const ctx = canvas.getContext("2d");63 if (!ctx) return;64 65 const prefersReduced =66 typeof window !== "undefined" &&67 window.matchMedia &&68 window.matchMedia("(prefers-reduced-motion: reduce)").matches;69 70 let animationFrameId: number;71 let dots: Dot[] = [];72 73 const setup = () => {74 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));75 const { innerWidth, innerHeight } = window;76 canvas.width = Math.floor(innerWidth * dpr);77 canvas.height = Math.floor(innerHeight * dpr);78 canvas.style.width = `${innerWidth}px`;79 canvas.style.height = `${innerHeight}px`;80 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);81 82 dots = [];83 const numDots = Math.floor((innerWidth * innerHeight) / 12000);84 for (let i = 0; i < numDots; ++i) {85 dots.push({86 x: Math.random() * innerWidth,87 y: Math.random() * innerHeight,88 radius: Math.random() * 2 + 0.3,89 speed: prefersReduced ? 0 : Math.random() * 0.3 + 0.05,90 opacity: Math.random() * 0.4 + 0.1,91 blur: Math.random() > 0.8 ? Math.random() * 1.5 + 0.5 : 0,92 pulse: Math.random() * Math.PI * 2,93 pulseSpeed: prefersReduced ? 0 : Math.random() * 0.02 + 0.01,94 });95 }96 };97 98 const draw = () => {99 if (!ctx) return;100 const width = canvas.clientWidth;101 const height = canvas.clientHeight;102 ctx.clearRect(0, 0, width, height);103 104 dots.forEach((dot) => {105 dot.y += dot.speed;106 dot.pulse += dot.pulseSpeed;107 108 if (dot.y > height + dot.radius) {109 dot.y = -dot.radius;110 dot.x = Math.random() * width;111 }112 113 const pulseFactor = 1 + Math.sin(dot.pulse) * 0.2;114 const currentRadius = dot.radius * pulseFactor;115 const currentOpacity = dot.opacity * (0.8 + Math.sin(dot.pulse) * 0.2);116 117 ctx.beginPath();118 ctx.arc(dot.x, dot.y, currentRadius, 0, Math.PI * 2);119 ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`;120 if (dot.blur > 0) ctx.filter = `blur(${dot.blur}px)`;121 ctx.fill();122 ctx.filter = "none";123 });124 125 animationFrameId = requestAnimationFrame(draw);126 };127 128 const handleResize = () => {129 cancelAnimationFrame(animationFrameId);130 setup();131 draw();132 };133 134 setup();135 draw();136 window.addEventListener("resize", handleResize);137 138 return () => {139 window.removeEventListener("resize", handleResize);140 cancelAnimationFrame(animationFrameId);141 };142 }, []);143 144 // Close dropdown on Escape / click outside145 useEffect(() => {146 if (!isModelDropdownOpen) return;147 148 const onKey = (e: KeyboardEvent) => {149 if (e.key === "Escape") setIsModelDropdownOpen(false);150 if (e.key === "ArrowDown") {151 e.preventDefault();152 setActiveIndex((i) => Math.min(MODEL_OPTIONS.length - 1, i + 1));153 }154 if (e.key === "ArrowUp") {155 e.preventDefault();156 setActiveIndex((i) => Math.max(0, i - 1));157 }158 if (e.key === "Enter") {159 e.preventDefault();160 const opt = MODEL_OPTIONS[activeIndex];161 if (opt) {162 handleModelSelect(opt.id);163 setIsModelDropdownOpen(false);164 dropdownBtnRef.current?.focus();165 }166 }167 };168 169 const onClick = (e: MouseEvent) => {170 const target = e.target as Node;171 if (172 dropdownRef.current &&173 !dropdownRef.current.contains(target) &&174 !dropdownBtnRef.current?.contains(target)175 ) {176 setIsModelDropdownOpen(false);177 }178 };179 180 document.addEventListener("keydown", onKey);181 document.addEventListener("mousedown", onClick);182 return () => {183 document.removeEventListener("keydown", onKey);184 document.removeEventListener("mousedown", onClick);185 };186 }, [187 isModelDropdownOpen,188 activeIndex,189 setIsModelDropdownOpen,190 handleModelSelect,191 ]);192 193 // Recompute portal position on open + resize194 const [, forceRerender] = useState(0);195 useEffect(() => {196 const onResize = () => forceRerender((x) => x + 1);197 window.addEventListener("resize", onResize);198 return () => window.removeEventListener("resize", onResize);199 }, []);200 201 // Compute portal style based on the whole button group (center + clamp + optional drop-up)202 const portalStyle = useMemo(() => {203 if (typeof window === "undefined") return {};204 const anchor = wrapperRef.current || dropdownBtnRef.current;205 if (!anchor) return {};206 207 const rect = anchor.getBoundingClientRect();208 209 const margin = 8;210 const minWidth = 320;211 const dropdownWidth = Math.max(rect.width, minWidth);212 213 // Center214 let left = Math.round(rect.left + rect.width / 2 - dropdownWidth / 2);215 // Clamp to viewport216 left = Math.min(217 Math.max(margin, left),218 window.innerWidth - dropdownWidth - margin219 );220 221 // Flip up if not enough space below222 const spaceBelow = window.innerHeight - rect.bottom;223 const spaceAbove = rect.top;224 const estimatedItemH = 56; // rough item height225 const estimatedPad = 16;226 const estimatedHeight =227 estimatedItemH * Math.min(MODEL_OPTIONS.length, 6) + estimatedPad;228 const dropUp = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;229 230 const top = dropUp ? rect.top - estimatedHeight - 8 : rect.bottom + 8;231 232 return {233 position: "fixed" as const,234 left: `${left}px`,235 top: `${top}px`,236 width: `${dropdownWidth}px`,237 zIndex: 100,238 };239 }, []);240 241 return (242 <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">243 {/* Background Canvas */}244 <canvas245 ref={canvasRef}246 className="absolute top-0 left-0 w-full h-full z-0"247 />248 249 {/* Vignette Overlay */}250 <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%)]" />251 252 {/* Grid Overlay */}253 <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]" />254 255 {/* Main Content */}256 <div className="relative z-20 max-w-4xl w-full flex flex-col items-center">257 {/* Logos */}258 <div className="flex items-center justify-center mb-8 gap-5">259 <a260 href="https://www.liquid.ai/"261 target="_blank"262 rel="noopener noreferrer"263 title="Liquid AI"264 className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"265 >266 <LiquidAILogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />267 </a>268 <span className="text-gray-500 text-3xl font-extralight">×</span>269 <a270 href="https://huggingface.co/docs/transformers.js"271 target="_blank"272 rel="noopener noreferrer"273 title="Transformers.js"274 className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"275 >276 <HfLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />277 </a>278 <span className="text-gray-500 text-3xl font-extralight">×</span>279 <a280 href="https://modelcontextprotocol.io/"281 target="_blank"282 rel="noopener noreferrer"283 title="Model Context Protocol"284 className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"285 >286 <MCPLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />287 </a>288 </div>289 290 {/* Hero */}291 <div className="text-center mb-8 space-y-4">292 <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">293 LFM2 MCP294 </h1>295 <p className="text-lg sm:text-xl md:text-2xl text-gray-300 font-light leading-relaxed">296 Run next-gen hybrid models in your browser with tools powered by the{" "}297 <a298 href="https://modelcontextprotocol.io/"299 target="_blank"300 rel="noopener noreferrer"301 >302 <span className="text-indigo-400 font-medium">303 Model Context Protocol (MCP)304 </span>{" "}305 enabling secure, real-time connections to remote servers.306 </a>307 </p>308 <div className="w-24 h-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full mx-auto" />309 </div>310 311 {/* Description Cards */}312 <div className="grid md:grid-cols-2 gap-6 text-gray-400 mb-10">313 <div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">314 <h3 className="text-white font-semibold mb-3 flex items-center">315 <div className="w-2 h-2 bg-indigo-500 rounded-full mr-3" />316 Model Context Protocol317 </h3>318 <p className="text-sm leading-relaxed">319 Connect seamlessly to remote{" "}320 <a321 href="https://modelcontextprotocol.io/"322 target="_blank"323 rel="noopener noreferrer"324 className="text-indigo-400 hover:underline"325 >326 MCP servers327 </a>{" "}328 using streaming or SSE protocols with support for no-auth, basic329 auth, and OAuth.330 </p>331 </div>332 333 <div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">334 <h3 className="text-white font-semibold mb-3 flex items-center">335 <div className="w-2 h-2 bg-purple-500 rounded-full mr-3" />336 Edge AI Technology337 </h3>338 <p className="text-sm leading-relaxed">339 Powered by{" "}340 <a341 href="https://www.liquid.ai/"342 target="_blank"343 rel="noopener noreferrer"344 className="text-indigo-400 hover:underline"345 >346 Liquid AI’s347 </a>{" "}348 LFM2 hybrid models, optimized for on-device deployment and edge AI349 scenarios.350 </p>351 </div>352 </div>353 354 <p className="text-gray-400 text-base sm:text-lg mb-10">355 Everything runs entirely in your browser with{" "}356 <a357 href="https://huggingface.co/docs/transformers.js"358 target="_blank"359 rel="noopener noreferrer"360 className="text-indigo-400 hover:underline font-medium"361 >362 Transformers.js363 </a>{" "}364 and ONNX Runtime Web.365 </p>366 367 {/* Action */}368 <div className="text-center space-y-6">369 <p className="text-gray-400 text-base sm:text-lg font-medium">370 Select a model to load locally, and connect to a remote MCP server371 to get started.372 </p>373 374 <div className="relative">375 <div376 ref={wrapperRef} // anchor for dropdown centering377 className="flex rounded-2xl shadow-2xl overflow-hidden"378 >379 <button380 onClick={isLoading ? undefined : loadSelectedModel}381 disabled={isLoading}382 className={`flex items-center justify-center font-bold transition-all text-lg flex-1 ${383 isLoading384 ? "bg-gray-700 text-gray-400 cursor-not-allowed"385 : "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]"386 }`}387 aria-live="polite"388 aria-busy={isLoading}389 aria-label={390 isLoading391 ? `Loading ${model?.label ?? "model"} ${progress}%`392 : `Load ${model?.label ?? "model"}`393 }394 >395 <div className="px-8 py-4">396 {isLoading ? (397 <div className="flex items-center">398 <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />399 <span className="ml-3 font-semibold">400 Loading... {progress}%401 </span>402 </div>403 ) : (404 <span className="font-semibold">Load {model?.label}</span>405 )}406 </div>407 </button>408 409 <button410 ref={dropdownBtnRef}411 onClick={(e) => {412 if (!isLoading) {413 e.stopPropagation();414 setIsModelDropdownOpen(!isModelDropdownOpen);415 setActiveIndex(416 Math.max(417 0,418 MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)419 )420 );421 }422 }}423 onKeyDown={(e) => {424 if (isLoading) return;425 if (426 e.key === " " ||427 e.key === "Enter" ||428 e.key === "ArrowDown"429 ) {430 e.preventDefault();431 if (!isModelDropdownOpen) setIsModelDropdownOpen(true);432 }433 }}434 aria-haspopup="menu"435 aria-expanded={isModelDropdownOpen}436 aria-controls="model-dropdown"437 aria-label="Select model"438 className={`px-4 py-4 border-l border-white/20 transition-all ${439 isLoading440 ? "bg-gray-700 cursor-not-allowed"441 : "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]"442 }`}443 disabled={isLoading}444 >445 <ChevronDown446 size={20}447 className={`transition-transform duration-200 ${448 isModelDropdownOpen ? "rotate-180" : ""449 }`}450 />451 </button>452 </div>453 454 {/* Dropdown (Portal) */}455 {isModelDropdownOpen &&456 typeof document !== "undefined" &&457 ReactDOM.createPortal(458 <div459 id="model-dropdown"460 ref={dropdownRef}461 style={portalStyle}462 role="menu"463 aria-label="Model options"464 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"465 >466 {MODEL_OPTIONS.map((option, index) => {467 const selected = selectedModelId === option.id;468 const isActive = activeIndex === index;469 return (470 <button471 key={option.id}472 role="menuitem"473 aria-checked={selected}474 onMouseEnter={() => setActiveIndex(index)}475 onClick={() => {476 handleModelSelect(option.id);477 setIsModelDropdownOpen(false);478 dropdownBtnRef.current?.focus();479 }}480 className={`w-full px-6 py-4 text-left transition-all duration-200 relative group outline-none ${481 selected482 ? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400"483 : "text-gray-200 hover:bg-white/10 hover:text-white"484 } ${index === 0 ? "rounded-t-2xl" : ""} ${485 index === MODEL_OPTIONS.length - 1486 ? "rounded-b-2xl"487 : ""488 } ${isActive && !selected ? "bg-white/5" : ""}`}489 >490 <div className="flex items-center justify-between">491 <div>492 <div className="font-semibold text-lg">493 {option.label}494 </div>495 <div className="text-sm text-gray-400 mt-1">496 {option.size}497 </div>498 </div>499 {selected && (500 <div className="w-2 h-2 bg-indigo-400 rounded-full" />501 )}502 </div>503 {!selected && (504 <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" />505 )}506 </button>507 );508 })}509 </div>,510 document.body511 )}512 </div>513 </div>514 515 {/* Error */}516 {error && (517 <div518 role="alert"519 className="bg-red-900/30 backdrop-blur-sm border border-red-500/50 rounded-2xl p-6 mt-8 max-w-md text-center"520 >521 <p className="text-red-200 mb-4 font-medium">Error: {error}</p>522 <button523 onClick={loadSelectedModel}524 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"525 >526 Try Again527 </button>528 </div>529 )}530 </div>531 532 {/* Click-away fallback for touch devices */}533 {isModelDropdownOpen && (534 <div535 className="fixed inset-0 z-40 bg-black/20"536 onClick={(e) => {537 const target = e.target as Node;538 if (539 dropdownRef.current &&540 !dropdownRef.current.contains(target) &&541 !dropdownBtnRef.current?.contains(target)542 ) {543 setIsModelDropdownOpen(false);544 }545 }}546 />547 )}548 </div>549 );550};551 