Download index.html from wop/fontend: direct link, hf CLI and curl.
- Browser
- Download file 72.3 kB
-
https://huggingface.co/spaces/wop/fontend/resolve/main/index.html
- Command line
-
hf download hf://spaces/wop/fontend/index.html
-
curl -L -o index.html https://huggingface.co/spaces/wop/fontend/resolve/main/index.html
72.3 kB
| <!-- templates/index.html --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="color-scheme" content="dark"> | |
| <title>Cosmos T3 Chat β Shatter Edition</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-deep: #07080f; | |
| --bg-main: #0b0d18; | |
| --bg-assistant: rgba(255, 255, 255, 0.025); | |
| --bg-input: rgba(17, 19, 33, 0.82); | |
| --text-main: #eceef6; | |
| --text-muted: #9da1b8; | |
| --accent-color: #facc15; | |
| --logo-yellow: #FACC15; | |
| --user-accent-1: #8b5cf6; | |
| --user-accent-2: #6d28d9; | |
| --border-soft: rgba(255, 255, 255, 0.08); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| ::selection { background: rgba(250, 204, 21, 0.28); } | |
| body { | |
| background: | |
| radial-gradient(1200px 800px at 82% -10%, rgba(139, 92, 246, 0.10), transparent 60%), | |
| radial-gradient(1000px 700px at 8% 110%, rgba(250, 204, 21, 0.06), transparent 60%), | |
| radial-gradient(900px 600px at 50% 45%, rgba(56, 78, 173, 0.08), transparent 65%), | |
| linear-gradient(180deg, var(--bg-main), var(--bg-deep)); | |
| color: var(--text-main); | |
| height: 100vh; | |
| display: flex; | |
| overflow: hidden; | |
| } | |
| /* ββ Starfield ββ */ | |
| .stars { | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 0; | |
| background-repeat: repeat; | |
| } | |
| .stars-1 { | |
| background-image: | |
| radial-gradient(1px 1px at 25px 35px, rgba(255, 255, 255, 0.9), transparent), | |
| radial-gradient(1px 1px at 120px 90px, rgba(255, 255, 255, 0.6), transparent), | |
| radial-gradient(1.5px 1.5px at 200px 160px, rgba(250, 204, 21, 0.7), transparent), | |
| radial-gradient(1px 1px at 80px 200px, rgba(255, 255, 255, 0.5), transparent), | |
| radial-gradient(1px 1px at 170px 40px, rgba(186, 196, 255, 0.7), transparent); | |
| background-size: 240px 240px; | |
| opacity: 0.5; | |
| animation: starDrift1 180s linear infinite, twinkle 7s ease-in-out infinite; | |
| } | |
| .stars-2 { | |
| background-image: | |
| radial-gradient(1px 1px at 60px 110px, rgba(255, 255, 255, 0.7), transparent), | |
| radial-gradient(1.5px 1.5px at 300px 250px, rgba(255, 255, 255, 0.5), transparent), | |
| radial-gradient(1px 1px at 200px 330px, rgba(186, 196, 255, 0.6), transparent), | |
| radial-gradient(1px 1px at 340px 60px, rgba(250, 204, 21, 0.5), transparent); | |
| background-size: 380px 380px; | |
| opacity: 0.35; | |
| animation: starDrift2 260s linear infinite, twinkle 9s ease-in-out infinite 2s; | |
| } | |
| @keyframes starDrift1 { from { background-position: 0 0; } to { background-position: -240px -480px; } } | |
| @keyframes starDrift2 { from { background-position: 0 0; } to { background-position: -380px -760px; } } | |
| @keyframes twinkle { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.5); } } | |
| #main { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| #chat-window { | |
| flex: 1; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| scroll-behavior: smooth; | |
| padding-top: 60px; /* Space for floating header bar */ | |
| } | |
| /* ββ Header Bar ββ */ | |
| .header-bar { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 60px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| padding: 0 24px; | |
| z-index: 100; | |
| background: linear-gradient(to bottom, rgba(7, 8, 15, 0.85) 50%, transparent); | |
| pointer-events: none; /* Let clicks pass through except on child buttons */ | |
| } | |
| .header-btn { | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--text-muted); | |
| border: 1px solid var(--border-soft); | |
| border-radius: 10px; | |
| padding: 8px 12px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s; | |
| pointer-events: auto; /* Enable clicks on the button */ | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| } | |
| .header-btn:hover:not(:disabled) { | |
| background: rgba(255, 255, 255, 0.08); | |
| border-color: rgba(250, 204, 21, 0.35); | |
| color: var(--text-main); | |
| box-shadow: 0 0 18px -6px rgba(250, 204, 21, 0.35); | |
| } | |
| .header-btn:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .header-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| /* ββ Empty State ββ */ | |
| .empty-state { | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| text-align: center; | |
| } | |
| .empty-state svg { | |
| width: 84px; | |
| height: auto; | |
| margin-bottom: 20px; | |
| opacity: 0.85; | |
| } | |
| .empty-state h1 { | |
| font-size: 46px; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| margin-bottom: 12px; | |
| background: linear-gradient(100deg, #fff 10%, var(--logo-yellow) 45%, #f59e0b 55%, #fff 90%); | |
| background-size: 200% auto; | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| color: transparent; | |
| animation: titleShimmer 6s linear infinite; | |
| } | |
| @keyframes titleShimmer { to { background-position: 200% center; } } | |
| .empty-sub { | |
| color: var(--text-muted); | |
| font-size: 15px; | |
| margin-bottom: 40px; | |
| } | |
| .grid-container { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 16px; | |
| max-width: 800px; | |
| width: 100%; | |
| } | |
| .grid-card { | |
| position: relative; | |
| padding: 18px; | |
| border: 1px solid var(--border-soft); | |
| border-radius: 14px; | |
| background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.015)); | |
| text-align: left; | |
| cursor: pointer; | |
| transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease; | |
| } | |
| .grid-card:hover { | |
| transform: translateY(-3px); | |
| border-color: rgba(250, 204, 21, 0.35); | |
| background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)); | |
| box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6), 0 0 24px -6px rgba(250, 204, 21, 0.18); | |
| } | |
| .card-icon { font-size: 20px; display: block; margin-bottom: 10px; } | |
| .grid-card .title { font-weight: 600; margin-bottom: 6px; } | |
| .grid-card .desc { font-size: 13px; color: var(--text-muted); line-height: 1.5; } | |
| /* ββ Messages ββ */ | |
| .message { | |
| padding: 26px 24px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.04); | |
| width: 100%; | |
| animation: fadeIn 0.35s ease-out; | |
| } | |
| .message.assistant { | |
| background: var(--bg-assistant); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); | |
| } | |
| .message.user { background: transparent; } | |
| .message-content { | |
| width: 100%; | |
| display: flex; | |
| gap: 16px; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(8px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ββ Message Actions (embedded in last assistant message) ββ */ | |
| .message-actions { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 12px; | |
| animation: fadeIn 0.2s ease-out; | |
| } | |
| .msg-action-btn { | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--text-muted); | |
| border: 1px solid var(--border-soft); | |
| border-radius: 8px; | |
| padding: 5px 10px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: background-color 0.2s, border-color 0.2s, color 0.2s; | |
| } | |
| .msg-action-btn:hover { | |
| background: rgba(255, 255, 255, 0.08); | |
| border-color: rgba(250, 204, 21, 0.35); | |
| color: var(--text-main); | |
| } | |
| .msg-action-btn svg { | |
| width: 12px; | |
| height: 12px; | |
| stroke: currentColor; | |
| } | |
| /* ββ Avatars ββ */ | |
| .avatar { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| color: white; | |
| } | |
| .avatar.assistant { | |
| background-color: transparent; | |
| padding: 4px; | |
| scale: 1.35; | |
| transition: transform 2s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .avatar.user { | |
| background: linear-gradient(135deg, var(--user-accent-1), var(--user-accent-2)); | |
| box-shadow: 0 4px 14px -4px rgba(139, 92, 246, 0.5); | |
| } | |
| .user-icon { width: 20px; height: 20px; } | |
| /* ββ SVG Base / Layout ββ */ | |
| .avatar.assistant svg { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .avatar.assistant svg, | |
| .empty-state svg { | |
| filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4)); | |
| transform-box: fill-box; | |
| transform-origin: center; | |
| will-change: transform; | |
| cursor: pointer; | |
| user-select: none; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| /* keep these color rules */ | |
| .avatar.assistant svg path, | |
| .empty-state svg path { | |
| stroke: var(--logo-yellow); | |
| } | |
| .avatar.assistant svg g path[fill="#000"], | |
| .empty-state svg g path[fill="#000"] { | |
| fill: var(--logo-yellow); | |
| } | |
| /* ββ Click flash on the whole SVG (glow only β the layers do the motion) ββ */ | |
| .avatar.assistant svg.clicked, | |
| .empty-state svg.clicked { | |
| animation: svgGlow 460ms ease-out; | |
| } | |
| @keyframes svgGlow { | |
| 0% { filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4)); } | |
| 25% { filter: drop-shadow(0 0 24px rgba(234, 179, 8, 1)); } | |
| 100% { filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4)); } | |
| } | |
| /* ββ Layer base transform setup (rotate around the SVG's true center) ββ */ | |
| .svg-layer-outer-1, | |
| .svg-layer-outer-2, | |
| .svg-layer-inner { | |
| transform-box: view-box; | |
| transform-origin: center; | |
| will-change: transform, rotate, scale; | |
| } | |
| /* ββ SVG Layer Animations (idle) β spin via `rotate`, breathe via `scale`/opacity. | |
| Keeping spin and pulse on SEPARATE properties lets every layer spin AND pulse | |
| at once, and lets the click burst (a `transform`) ride on top of the spin. ββ */ | |
| .svg-layer-outer-1 { | |
| animation: spinCW 20s linear infinite, pulseOuter1 5s ease-in-out infinite; | |
| } | |
| .svg-layer-outer-2 { | |
| animation: spinCCW 25s linear infinite, breatheOuter2 6s ease-in-out infinite 1s; | |
| } | |
| .svg-layer-inner { | |
| animation: spinCW 15s linear infinite, breatheInner 4s ease-in-out infinite 0.5s; | |
| } | |
| @keyframes spinCW { from { rotate: 0deg; } to { rotate: 360deg; } } | |
| @keyframes spinCCW { from { rotate: 0deg; } to { rotate: -360deg; } } | |
| @keyframes pulseOuter1 { 0%, 100% { opacity: 1; scale: 1; } 50% { opacity: 0.65; scale: 1.04; } } | |
| @keyframes breatheOuter2 { 0%, 100% { scale: 1; } 50% { scale: 1.06; } } | |
| @keyframes breatheInner { 0%, 100% { scale: 1; } 50% { scale: 1.15; } } | |
| /* ββ SVG Layer Animations (typing / generating) ββ */ | |
| .avatar.typing .svg-layer-outer-1 { | |
| animation: spinCW 6s linear infinite, pulseOuter1 2s ease-in-out infinite; | |
| } | |
| .avatar.typing .svg-layer-outer-2 { | |
| animation: spinCCW 8s linear infinite, breatheOuter2 2.5s ease-in-out infinite 0.3s; | |
| } | |
| .avatar.typing .svg-layer-inner { | |
| animation: spinCW 4s linear infinite, breatheInner 1.5s ease-in-out infinite 0.2s; | |
| } | |
| /* ββ Text Area ββ */ | |
| .text-area { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| min-width: 0; | |
| } | |
| .role-label { | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--text-muted); | |
| opacity: 0.9; | |
| } | |
| .message.assistant .role-label { | |
| color: var(--logo-yellow); | |
| opacity: 0.75; | |
| } | |
| .content-text { | |
| max-width: 100%; | |
| line-height: 1.65; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| /* ββ Streaming Cursor ββ */ | |
| .content-text.streaming::after { | |
| content: 'β'; | |
| display: inline-block; | |
| animation: blink 1s steps(2) infinite; | |
| margin-left: 1px; | |
| color: var(--logo-yellow); | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| } | |
| /* ββ Input Area ββ */ | |
| .input-container { | |
| padding: 20px 24px 16px; | |
| background: linear-gradient(transparent, rgba(11, 13, 24, 0.92) 40%); | |
| position: relative; | |
| z-index: 2; | |
| } | |
| .input-wrapper { | |
| width: 100%; | |
| position: relative; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| textarea { | |
| width: 100%; | |
| background: var(--bg-input); | |
| color: var(--text-main); | |
| border: 1px solid var(--border-soft); | |
| border-radius: 16px; | |
| padding: 14px 54px 14px 18px; | |
| resize: none; | |
| outline: none; | |
| font-size: 16px; | |
| font-family: inherit; | |
| box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6); | |
| min-height: 52px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| caret-color: var(--logo-yellow); | |
| backdrop-filter: blur(14px); | |
| -webkit-backdrop-filter: blur(14px); | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| } | |
| textarea:focus { | |
| border-color: rgba(250, 204, 21, 0.45); | |
| box-shadow: 0 0 0 3px rgba(250, 204, 21, 0.12), 0 12px 32px -12px rgba(0, 0, 0, 0.6); | |
| } | |
| textarea::placeholder { color: #6f7390; } | |
| .send-btn { | |
| position: absolute; | |
| right: 10px; | |
| bottom: 10px; | |
| width: 34px; | |
| height: 34px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(255, 255, 255, 0.06); | |
| border: none; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| border-radius: 10px; | |
| transition: background 0.2s, color 0.2s, transform 0.15s, box-shadow 0.2s; | |
| } | |
| .send-btn:not(:disabled):hover { | |
| background: var(--logo-yellow); | |
| color: #1a1505; | |
| transform: scale(1.06); | |
| box-shadow: 0 4px 16px -2px rgba(250, 204, 21, 0.5); | |
| } | |
| .send-btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| .send-btn.stop-btn { | |
| color: #f87171; | |
| background: rgba(239, 68, 68, 0.12); | |
| } | |
| .send-btn.stop-btn:hover { | |
| background: rgba(239, 68, 68, 0.25) ; | |
| color: #fca5a5 ; | |
| box-shadow: 0 4px 16px -2px rgba(239, 68, 68, 0.4) ; | |
| } | |
| .footer-note { | |
| text-align: center; | |
| font-size: 11px; | |
| letter-spacing: 0.06em; | |
| color: #6b6f85; | |
| margin-top: 12px; | |
| } | |
| /* ββ Typing Indicator ββ */ | |
| .typing-dots { | |
| display: flex; | |
| gap: 4px; | |
| padding: 12px 0; | |
| align-items: center; | |
| } | |
| .dot { | |
| width: 8px; | |
| height: 8px; | |
| background: var(--logo-yellow); | |
| border-radius: 50%; | |
| animation: bounce 1.4s infinite ease-in-out both; | |
| } | |
| .dot:nth-child(1) { animation-delay: -0.32s; } | |
| .dot:nth-child(2) { animation-delay: -0.16s; } | |
| @keyframes bounce { | |
| 0%, 80%, 100% { transform: scale(0); } | |
| 40% { transform: scale(1.0); } | |
| } | |
| /* ββ Error Toast ββ */ | |
| .error-toast { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(220, 38, 38, 0.92); | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| color: white; | |
| padding: 12px 24px; | |
| border-radius: 10px; | |
| font-size: 14px; | |
| z-index: 1000; | |
| box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| animation: toastIn 0.3s ease-out, toastOut 0.3s ease-in 3.7s forwards; | |
| } | |
| @keyframes toastIn { | |
| from { opacity: 0; transform: translateX(-50%) translateY(-10px); } | |
| to { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| } | |
| @keyframes toastOut { | |
| from { opacity: 1; } | |
| to { opacity: 0; } | |
| } | |
| /* ββ Scrollbar ββ */ | |
| #chat-window::-webkit-scrollbar { width: 8px; } | |
| #chat-window::-webkit-scrollbar-track { background: transparent; } | |
| #chat-window::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } | |
| #chat-window::-webkit-scrollbar-thumb:hover { background: rgba(250, 204, 21, 0.3); } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 600px) { | |
| .grid-container { grid-template-columns: 1fr; } | |
| .message { padding: 16px 12px; } | |
| .input-container { padding: 16px 12px; } | |
| .empty-state h1 { font-size: 30px; } | |
| .message-actions { | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .msg-action-btn { | |
| padding: 4px 8px; | |
| font-size: 11px; | |
| flex: 1 1 auto; | |
| justify-content: center; | |
| } | |
| } | |
| /* ββ Loading Screen ββ */ | |
| #loading-screen { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 2000; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 26px; | |
| background: radial-gradient(circle at 50% 42%, #13152a 0%, var(--bg-deep) 70%); | |
| transition: opacity 0.6s ease, visibility 0.6s ease; | |
| } | |
| #loading-screen.hidden { | |
| opacity: 0; | |
| visibility: hidden; | |
| pointer-events: none; | |
| } | |
| .loading-logo { | |
| width: 130px; | |
| height: 130px; | |
| animation: loaderGlow 2.4s ease-in-out infinite; | |
| } | |
| .loading-logo svg { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .loading-logo svg path { stroke: var(--logo-yellow); } | |
| .loading-logo svg g path[fill="#000"] { fill: var(--logo-yellow); } | |
| @keyframes loaderGlow { | |
| 0%, 100% { filter: drop-shadow(0 0 14px rgba(234, 179, 8, 0.45)); } | |
| 50% { filter: drop-shadow(0 0 32px rgba(234, 179, 8, 0.85)); } | |
| } | |
| .loading-title { | |
| font-size: 20px; | |
| font-weight: 700; | |
| letter-spacing: 0.22em; | |
| text-transform: uppercase; | |
| color: var(--text-main); | |
| animation: loaderTitlePulse 2.4s ease-in-out infinite; | |
| } | |
| @keyframes loaderTitlePulse { | |
| 0%, 100% { opacity: 0.4; } | |
| 50% { opacity: 0.9; } | |
| } | |
| .loading-bar { | |
| position: relative; | |
| width: 180px; | |
| height: 3px; | |
| border-radius: 3px; | |
| background: rgba(255, 255, 255, 0.08); | |
| overflow: hidden; | |
| } | |
| .loading-bar::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -40%; | |
| width: 40%; | |
| height: 100%; | |
| border-radius: 3px; | |
| background: linear-gradient(90deg, transparent, var(--logo-yellow), transparent); | |
| animation: loaderSlide 1.15s ease-in-out infinite; | |
| } | |
| @keyframes loaderSlide { | |
| 0% { left: -40%; } | |
| 100% { left: 100%; } | |
| } | |
| /* ββ Glass Shatter Effect ββ */ | |
| #glass-pane { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 1500; | |
| background: | |
| linear-gradient(115deg, | |
| rgba(255, 255, 255, 0.10) 0%, | |
| rgba(255, 255, 255, 0.02) 28%, | |
| rgba(255, 255, 255, 0.09) 42%, | |
| rgba(255, 255, 255, 0.02) 56%, | |
| rgba(255, 255, 255, 0.06) 100%); | |
| backdrop-filter: blur(1.5px) brightness(1.05); | |
| -webkit-backdrop-filter: blur(1.5px) brightness(1.05); | |
| opacity: 0; | |
| transition: opacity 0.45s ease; | |
| cursor: crosshair; | |
| } | |
| #glass-pane.on { opacity: 1; } | |
| #glass-pane.broken { | |
| background: none; | |
| backdrop-filter: none; | |
| -webkit-backdrop-filter: none; | |
| cursor: default; | |
| pointer-events: none; | |
| } | |
| .glass-hint { | |
| position: absolute; | |
| left: 50%; | |
| bottom: 12%; | |
| transform: translateX(-50%); | |
| font-size: 13px; | |
| letter-spacing: 0.25em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.65); | |
| text-shadow: 0 0 12px rgba(255, 255, 255, 0.4); | |
| animation: hintPulse 2.2s ease-in-out infinite; | |
| pointer-events: none; | |
| white-space: nowrap; | |
| } | |
| @keyframes hintPulse { | |
| 0%, 100% { opacity: 0.35; } | |
| 50% { opacity: 0.9; } | |
| } | |
| .glass-shard { | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 1501; | |
| will-change: transform, opacity; | |
| } | |
| .glass-flash { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 1502; | |
| pointer-events: none; | |
| } | |
| #shatter-btn { | |
| position: fixed; | |
| right: 18px; | |
| bottom: 18px; | |
| z-index: 1400; | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(255, 255, 255, 0.06); | |
| border: 1px solid var(--border-soft); | |
| color: var(--text-muted); | |
| font-size: 18px; | |
| cursor: pointer; | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; | |
| } | |
| #shatter-btn:hover { | |
| transform: scale(1.08); | |
| border-color: rgba(250, 204, 21, 0.4); | |
| box-shadow: 0 0 18px -4px rgba(250, 204, 21, 0.4); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ββ Animated starfield background ββ --> | |
| <div class="stars stars-1"></div> | |
| <div class="stars stars-2"></div> | |
| <!-- ββ Loading splash: spinning logo shown until the app is ready ββ --> | |
| <div id="loading-screen"> | |
| <div class="loading-logo"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.217 108.55"> | |
| <defs> | |
| <clipPath id="loaderClip"><path fill="none" d="M0 0h116.217v108.55H0z"/></clipPath> | |
| </defs> | |
| <g clip-path="url(#loaderClip)" fill="none" stroke-width="none" stroke-miterlimit="10" | |
| font-family="none" font-weight="none" font-size="none" | |
| text-anchor="none" style="mix-blend-mode:normal"> | |
| <g class="svg-layer-outer-1"> | |
| <path d="M40.9 92.92c-5.487-1.788-6.775-9.344-10.164-14.014-4.894-6.742-12.956-12.074-14.684-20.224-1.196-5.643 4.697-10.537 7.045-15.806 3.39-7.608 3.974-17.256 10.167-22.827 4.29-3.857 11.475-1.199 17.212-1.8 8.284-.868 16.931-5.187 24.851-2.608 5.487 1.787 6.776 9.343 10.165 14.013 4.89 6.74 12.948 12.07 14.673 20.217 1.194 5.643-4.7 10.536-7.048 15.805-3.39 7.609-3.974 17.257-10.167 22.827-4.3 3.867-34.124 6.997-42.05 4.416z" stroke="#000" stroke-width="8"/> | |
| </g> | |
| <g class="svg-layer-outer-2"> | |
| <path d="M77.404 104.55c-6.547 3.33-14.509-2.311-21.763-3.464-10.474-1.664-22.531.793-31.422-4.988-6.156-4.002-5.257-13.713-7.887-20.568-3.797-9.9-11.955-19.111-11.395-29.7.388-7.333 9.25-11.408 13.873-17.114C25.485 20.478 29.38 8.806 38.832 4 45.379.67 53.34 6.31 60.595 7.464c10.468 1.664 22.52-.79 31.405 4.99 6.156 4.005 5.255 13.715 7.884 20.571 3.797 9.9 11.956 19.11 11.395 29.7-.389 7.35-24.417 37.014-33.875 41.825z" stroke="#000" stroke-width="8"/> | |
| </g> | |
| <g class="svg-layer-inner"> | |
| <path d="M35.33 63.017c-1.51-2.966 1.047-6.573 1.57-9.86.753-4.746-.36-10.209 2.259-14.237 1.813-2.79 6.213-2.382 9.32-3.573 4.485-1.72 8.658-5.417 13.456-5.163 3.322.176 5.169 4.19 7.754 6.285 3.732 3.025 9.02 4.79 11.198 9.072 1.509 2.967-1.047 6.574-1.57 9.86-.753 4.744.359 10.204-2.26 14.23-1.815 2.789-6.214 2.38-9.32 3.572-4.486 1.72-8.66 5.417-13.457 5.163-3.33-.176-16.77-11.063-18.95-15.349" fill="#000"/> | |
| </g> | |
| </g> | |
| </svg> | |
| </div> | |
| <div class="loading-title">Cosmos T3</div> | |
| <div class="loading-bar"></div> | |
| </div> | |
| <main id="main"> | |
| <!-- Floating Header Bar containing the Clear Chat button --> | |
| <div id="header-bar" class="header-bar" style="display: none;"> | |
| <button | |
| id="clear-btn" | |
| class="header-btn" | |
| onclick="onClearClick()" | |
| aria-label="Clear chat" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="3 6 5 6 21 6"></polyline> | |
| <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> | |
| <line x1="10" y1="11" x2="10" y2="17"></line> | |
| <line x1="14" y1="11" x2="14" y2="17"></line> | |
| </svg> | |
| Clear Chat | |
| </button> | |
| </div> | |
| <div id="chat-window"></div> | |
| <div class="input-container"> | |
| <div class="input-wrapper"> | |
| <textarea | |
| id="user-input" | |
| rows="1" | |
| placeholder="Send a message..." | |
| aria-label="Chat input" | |
| ></textarea> | |
| <button | |
| id="send-btn" | |
| class="send-btn" | |
| onclick="onSendClick()" | |
| aria-label="Send message" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" | |
| viewBox="0 0 24 24" fill="none" stroke="currentColor" | |
| stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="22" y1="2" x2="11" y2="13"></line> | |
| <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="footer-note"> | |
| Powered by Cosmos T3 BASE | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Backend configuration (Gradio Space API) | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // The static frontend talks to the Gradio backend Space. | |
| // Gradio exposes a stateless two-step REST per fn: | |
| // 1) POST {BASE}/call/{FN} with {"data": [...]} -> { "event_id": "..." } | |
| // 2) GET {BASE}/call/{FN}/{event_id} -> SSE stream | |
| // The SSE emits: | |
| // event: generating data: ["<full growing text>", null] | |
| // event: heartbeat (keepalive, ignored) | |
| // event: complete data: ["<final full text>", null] | |
| // Note: each "generating" data carries the FULL snapshot, not a delta. | |
| const BACKEND_BASE = 'https://wop-server-backend.hf.space/gradio_api'; | |
| const CHAT_FN = 'chat'; // single self-contained streaming fn: inputs [message, state] | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // SVG Templates | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| const modelLogoSvg = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.217 108.55"> | |
| <defs> | |
| <clipPath id="a"><path fill="none" d="M0 0h116.217v108.55H0z"/></clipPath> | |
| </defs> | |
| <g clip-path="url(#a)" fill="none" stroke-width="none" stroke-miterlimit="10" | |
| font-family="none" font-weight="none" font-size="none" | |
| text-anchor="none" style="mix-blend-mode:normal"> | |
| <g class="svg-layer-outer-1"> | |
| <path d="M40.9 92.92c-5.487-1.788-6.775-9.344-10.164-14.014-4.894-6.742-12.956-12.074-14.684-20.224-1.196-5.643 4.697-10.537 7.045-15.806 3.39-7.608 3.974-17.256 10.167-22.827 4.29-3.857 11.475-1.199 17.212-1.8 8.284-.868 16.931-5.187 24.851-2.608 5.487 1.787 6.776 9.343 10.165 14.013 4.89 6.74 12.948 12.07 14.673 20.217 1.194 5.643-4.7 10.536-7.048 15.805-3.39 7.609-3.974 17.257-10.167 22.827-4.3 3.867-34.124 6.997-42.05 4.416z" | |
| stroke="#000" stroke-width="8"/> | |
| </g> | |
| <g class="svg-layer-outer-2"> | |
| <path d="M77.404 104.55c-6.547 3.33-14.509-2.311-21.763-3.464-10.474-1.664-22.531.793-31.422-4.988-6.156-4.002-5.257-13.713-7.887-20.568-3.797-9.9-11.955-19.111-11.395-29.7.388-7.333 9.25-11.408 13.873-17.114C25.485 20.478 29.38 8.806 38.832 4 45.379.67 53.34 6.31 60.595 7.464c10.468 1.664 22.52-.79 31.405 4.99 6.156 4.005 5.255 13.715 7.884 20.571 3.797 9.9 11.956 19.11 11.395 29.7-.389 7.35-24.417 37.014-33.875 41.825z" | |
| stroke="#000" stroke-width="8"/> | |
| </g> | |
| <g class="svg-layer-inner"> | |
| <path d="M35.33 63.017c-1.51-2.966 1.047-6.573 1.57-9.86.753-4.746-.36-10.209 2.259-14.237 1.813-2.79 6.213-2.382 9.32-3.573 4.485-1.72 8.658-5.417 13.456-5.163 3.322.176 5.169 4.19 7.754 6.285 3.732 3.025 9.02 4.79 11.198 9.072 1.509 2.967-1.047 6.574-1.57 9.86-.753 4.744.359 10.204-2.26 14.23-1.815 2.789-6.214 2.38-9.32 3.572-4.486 1.72-8.66 5.417-13.457 5.163-3.33-.176-16.77-11.063-18.95-15.349" | |
| fill="#000"/> | |
| </g> | |
| </g> | |
| </svg>`; | |
| const userIconSvg = ` | |
| <svg class="user-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" | |
| fill="none" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> | |
| <circle cx="12" cy="7" r="4"></circle> | |
| </svg>`; | |
| const sendIconSvg = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" | |
| viewBox="0 0 24 24" fill="none" stroke="currentColor" | |
| stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="22" y1="2" x2="11" y2="13"></line> | |
| <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> | |
| </svg>`; | |
| const stopIconSvg = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" | |
| viewBox="0 0 24 24" fill="currentColor"> | |
| <rect x="6" y="6" width="12" height="12" rx="2"/> | |
| </svg>`; | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Session / State | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| const SESSION_KEY = 'lfm_session_id'; | |
| let sessionId = localStorage.getItem(SESSION_KEY); | |
| if (!sessionId) { | |
| sessionId = (crypto.randomUUID && crypto.randomUUID()) || | |
| (Date.now().toString(36) + Math.random().toString(36).slice(2)); | |
| localStorage.setItem(SESSION_KEY, sessionId); | |
| } | |
| let messages = []; | |
| let isStreaming = false; | |
| let abortController = null; | |
| let activeAssistantMsg = null; | |
| let activeAssistantContentEl = null; | |
| let activeAvatarEl = null; | |
| const chatWindowEl = document.getElementById('chat-window'); | |
| const userInputEl = document.getElementById('user-input'); | |
| const sendBtnEl = document.getElementById('send-btn'); | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Utilities | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text ?? ''; | |
| return div.innerHTML; | |
| } | |
| function scrollToBottom() { | |
| requestAnimationFrame(() => { | |
| chatWindowEl.scrollTop = chatWindowEl.scrollHeight; | |
| }); | |
| } | |
| function showErrorToast(message) { | |
| const existing = document.querySelector('.error-toast'); | |
| if (existing) existing.remove(); | |
| const toast = document.createElement('div'); | |
| toast.className = 'error-toast'; | |
| toast.textContent = message; | |
| document.body.appendChild(toast); | |
| setTimeout(() => toast.remove(), 4000); | |
| } | |
| function setSendButton(mode) { | |
| if (mode === 'stop') { | |
| sendBtnEl.innerHTML = stopIconSvg; | |
| sendBtnEl.classList.add('stop-btn'); | |
| sendBtnEl.disabled = false; | |
| sendBtnEl.setAttribute('aria-label', 'Stop generating'); | |
| } else { | |
| sendBtnEl.innerHTML = sendIconSvg; | |
| sendBtnEl.classList.remove('stop-btn'); | |
| sendBtnEl.disabled = false; | |
| sendBtnEl.setAttribute('aria-label', 'Send message'); | |
| } | |
| } | |
| function normalizeRole(role) { | |
| return role === 'user' || role === 'assistant' || role === 'system' ? role : null; | |
| } | |
| // Filled when template card is clicked | |
| function fillPrompt(text) { | |
| userInputEl.value = text; | |
| autoResizeTextarea(); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Header Controller (for Clear Chat) | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateHeader() { | |
| const headerBar = document.getElementById('header-bar'); | |
| const clearBtn = document.getElementById('clear-btn'); | |
| if (messages.length === 0) { | |
| headerBar.style.display = 'none'; | |
| return; | |
| } | |
| headerBar.style.display = 'flex'; | |
| clearBtn.disabled = isStreaming; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Rendering | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderMessages() { | |
| chatWindowEl.innerHTML = ''; | |
| if (messages.length === 0) { | |
| chatWindowEl.innerHTML = ` | |
| <div class="empty-state"> | |
| ${modelLogoSvg} | |
| <h1>Cosmos T3</h1> | |
| <div class="empty-sub">Ask anything. Explore everything.</div> | |
| <div class="grid-container"> | |
| <div class="grid-card" onclick="fillPrompt('Explain quantum computing in simple terms')"> | |
| <span class="card-icon">π‘</span> | |
| <div class="title">Examples</div> | |
| <div class="desc">"Explain quantum computing in simple terms"</div> | |
| </div> | |
| <div class="grid-card" onclick="fillPrompt('Write a short creative poem about the ocean')"> | |
| <span class="card-icon">β¨</span> | |
| <div class="title">Creative</div> | |
| <div class="desc">"Write a short poem about the ocean"</div> | |
| </div> | |
| <div class="grid-card" onclick="fillPrompt('What is the meaning of life?')"> | |
| <span class="card-icon">π</span> | |
| <div class="title">Philosophy</div> | |
| <div class="desc">"What is the meaning of life?"</div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| updateHeader(); | |
| return; | |
| } | |
| for (let i = 0; i < messages.length; i++) { | |
| const msg = messages[i]; | |
| const role = normalizeRole(msg.role); | |
| if (!role) continue; | |
| const isLastMessage = (i === messages.length - 1); | |
| const isLastAssistantMessage = isLastMessage && role === 'assistant'; | |
| const msgDiv = document.createElement('div'); | |
| msgDiv.className = `message ${role}`; | |
| const avatarContent = role === 'assistant' ? modelLogoSvg : userIconSvg; | |
| const roleLabel = role === 'assistant' ? 'Cosmos T3' : (role === 'system' ? 'System' : 'You'); | |
| // Render action buttons directly inside the last assistant message | |
| let actionsHtml = ''; | |
| if (isLastAssistantMessage && !isStreaming) { | |
| const hasUserMsg = messages.some(m => m.role === 'user'); | |
| actionsHtml = ` | |
| <div class="message-actions"> | |
| ${hasUserMsg ? ` | |
| <button | |
| class="msg-action-btn" | |
| onclick="onRegenerateClick()" | |
| aria-label="Regenerate response" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="23 4 23 10 17 10"></polyline> | |
| <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path> | |
| </svg> | |
| Regenerate | |
| </button> | |
| ` : ''} | |
| <button | |
| class="msg-action-btn" | |
| onclick="onContinueClick()" | |
| aria-label="Continue generating" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="9 18 15 12 9 6"></polyline> | |
| </svg> | |
| Continue Generating | |
| </button> | |
| </div> | |
| `; | |
| } | |
| msgDiv.innerHTML = ` | |
| <div class="message-content"> | |
| <div class="avatar ${role}">${avatarContent}</div> | |
| <div class="text-area"> | |
| <div class="role-label">${roleLabel}</div> | |
| <div class="content-text">${escapeHtml(msg.content)}</div> | |
| ${actionsHtml} | |
| </div> | |
| </div> | |
| `; | |
| chatWindowEl.appendChild(msgDiv); | |
| } | |
| scrollToBottom(); | |
| updateHeader(); | |
| } | |
| function showTypingIndicator() { | |
| removeTypingIndicator(); | |
| const indicator = document.createElement('div'); | |
| indicator.className = 'message assistant'; | |
| indicator.id = 'typing-indicator'; | |
| indicator.innerHTML = ` | |
| <div class="message-content"> | |
| <div class="avatar assistant typing">${modelLogoSvg}</div> | |
| <div class="text-area"> | |
| <div class="role-label">Cosmos T3</div> | |
| <div class="typing-dots"> | |
| <div class="dot"></div> | |
| <div class="dot"></div> | |
| <div class="dot"></div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| chatWindowEl.appendChild(indicator); | |
| scrollToBottom(); | |
| } | |
| function removeTypingIndicator() { | |
| const el = document.getElementById('typing-indicator'); | |
| if (el) el.remove(); | |
| } | |
| function createAssistantPlaceholder() { | |
| const assistantMsg = { role: 'assistant', content: '' }; | |
| messages.push(assistantMsg); | |
| renderMessages(); | |
| const contentEls = chatWindowEl.querySelectorAll('.content-text'); | |
| const lastContentEl = contentEls[contentEls.length - 1]; | |
| const lastMsgEl = lastContentEl ? lastContentEl.closest('.message') : null; | |
| const avatarEl = lastMsgEl ? lastMsgEl.querySelector('.avatar.assistant') : null; | |
| if (lastContentEl) lastContentEl.classList.add('streaming'); | |
| if (avatarEl) avatarEl.classList.add('typing'); | |
| activeAssistantMsg = assistantMsg; | |
| activeAssistantContentEl = lastContentEl; | |
| activeAvatarEl = avatarEl; | |
| } | |
| function clearAssistantStreamingUI() { | |
| if (activeAssistantContentEl) { | |
| activeAssistantContentEl.classList.remove('streaming'); | |
| } | |
| if (activeAvatarEl) { | |
| activeAvatarEl.classList.remove('typing'); | |
| } | |
| activeAssistantContentEl = null; | |
| activeAvatarEl = null; | |
| } | |
| // Set the assistant's full content (the backend streams full snapshots). | |
| function setAssistantContent(fullText) { | |
| if (activeAssistantMsg) { | |
| activeAssistantMsg.content = fullText; | |
| } | |
| if (activeAssistantContentEl) { | |
| activeAssistantContentEl.textContent = fullText; | |
| scrollToBottom(); | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // SSE helpers (Gradio "event:/data:" blocks) | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function parseSSEEvents(buffer) { | |
| const events = buffer.split('\n\n'); | |
| const rest = events.pop(); | |
| return { events, rest }; | |
| } | |
| // Parse one SSE block into { event, data } where data is the joined data: payload. | |
| function parseEventBlock(block) { | |
| const lines = block.split('\n'); | |
| let eventName = 'message'; | |
| const dataLines = []; | |
| for (const line of lines) { | |
| if (line.startsWith('event:')) { | |
| eventName = line.slice(6).trim(); | |
| } else if (line.startsWith('data:')) { | |
| dataLines.push(line.slice(5).trimStart()); | |
| } | |
| } | |
| return { event: eventName, data: dataLines.length ? dataLines.join('\n') : null }; | |
| } | |
| // Gradio "chat" fn outputs ["<full text>", null]. Pull the text out robustly. | |
| function extractTextFromGradioData(parsed) { | |
| if (parsed == null) return null; | |
| if (typeof parsed === 'string') return parsed; | |
| if (Array.isArray(parsed)) { | |
| const first = parsed[0]; | |
| if (typeof first === 'string') return first; | |
| // ChatMessage-style object fallback | |
| if (first && typeof first === 'object') { | |
| if (typeof first.content === 'string') return first.content; | |
| if (Array.isArray(first.content)) { | |
| return first.content | |
| .map(c => (c && typeof c.text === 'string') ? c.text : '') | |
| .join(''); | |
| } | |
| } | |
| return null; | |
| } | |
| if (typeof parsed === 'object') { | |
| if (typeof parsed.content === 'string') return parsed.content; | |
| } | |
| return null; | |
| } | |
| async function readErrorBody(response) { | |
| try { | |
| const ct = response.headers.get('content-type') || ''; | |
| if (ct.includes('application/json')) { | |
| const j = await response.json(); | |
| return j.error || JSON.stringify(j); | |
| } | |
| return await response.text(); | |
| } catch { | |
| return `HTTP ${response.status}`; | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Backend call: Gradio two-step streaming | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Step 1: enqueue -> event_id ; Step 2: open SSE and feed onToken(fullText). | |
| async function streamGradioChat({ message, history, onFullText, signal }) { | |
| // Step 1 β POST to enqueue. Returns { event_id }. | |
| const joinRes = await fetch(`${BACKEND_BASE}/call/${CHAT_FN}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| // chat fn signature: [message, state]. We pass null for state | |
| // (the backend manages its own conversation context). | |
| // NOTE: do NOT send session_hash here β with the stateless | |
| // /call/{fn} REST it routes the event into a session queue that | |
| // the GET /call/{fn}/{event_id} stream cannot drain (hangs/404). | |
| data: [message, null] | |
| }), | |
| signal | |
| }); | |
| if (!joinRes.ok) { | |
| throw new Error(await readErrorBody(joinRes) || `Server error: HTTP ${joinRes.status}`); | |
| } | |
| const joinJson = await joinRes.json(); | |
| const eventId = joinJson.event_id || joinJson.eventId; | |
| if (!eventId) { | |
| throw new Error('Backend did not return an event_id.'); | |
| } | |
| // Step 2 β open the SSE stream for this event. | |
| const streamRes = await fetch(`${BACKEND_BASE}/call/${CHAT_FN}/${eventId}`, { | |
| method: 'GET', | |
| headers: { 'Accept': 'text/event-stream' }, | |
| signal | |
| }); | |
| if (!streamRes.ok || !streamRes.body) { | |
| throw new Error(await readErrorBody(streamRes) || `Stream error: HTTP ${streamRes.status}`); | |
| } | |
| const reader = streamRes.body.getReader(); | |
| const decoder = new TextDecoder('utf-8'); | |
| let buffer = ''; | |
| let finished = false; | |
| let lastText = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parsed = parseSSEEvents(buffer); | |
| buffer = parsed.rest; | |
| for (const block of parsed.events) { | |
| if (!block.trim()) continue; | |
| const { event, data } = parseEventBlock(block); | |
| // Keepalive β ignore. | |
| if (event === 'heartbeat') continue; | |
| // Backend-signalled error. | |
| if (event === 'error') { | |
| let msg = data || 'Unknown backend error'; | |
| try { | |
| const j = JSON.parse(data); | |
| msg = (typeof j === 'string') ? j : (j.error || JSON.stringify(j)); | |
| } catch { /* keep raw */ } | |
| throw new Error(msg); | |
| } | |
| if (!data) continue; | |
| let payload; | |
| try { | |
| payload = JSON.parse(data); | |
| } catch { | |
| // Non-JSON data line; skip. | |
| continue; | |
| } | |
| const text = extractTextFromGradioData(payload); | |
| if (event === 'generating') { | |
| if (typeof text === 'string') { | |
| lastText = text; | |
| onFullText(text); | |
| } | |
| } else if (event === 'complete') { | |
| if (typeof text === 'string') { | |
| lastText = text; | |
| onFullText(text); | |
| } | |
| finished = true; | |
| } | |
| } | |
| } | |
| return { finished, lastText }; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Main send / stop / regenerate / continue / clear logic | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function onSendClick() { | |
| if (isStreaming && abortController) { | |
| abortController.abort(); | |
| return; | |
| } | |
| handleSend(); | |
| } | |
| function onClearClick() { | |
| if (isStreaming && abortController) { | |
| abortController.abort(); | |
| } | |
| messages = []; | |
| renderMessages(); | |
| } | |
| function onRegenerateClick() { | |
| if (isStreaming || messages.length === 0) return; | |
| // Find last assistant message and remove it if it exists | |
| const lastMsg = messages[messages.length - 1]; | |
| if (lastMsg && lastMsg.role === 'assistant') { | |
| messages.pop(); | |
| } | |
| // Get the new last message, which must be from the user | |
| const newLastMsg = messages[messages.length - 1]; | |
| if (!newLastMsg || newLastMsg.role !== 'user') { | |
| renderMessages(); | |
| return; | |
| } | |
| const text = newLastMsg.content; | |
| executeChatStream(text, true); | |
| } | |
| function onContinueClick() { | |
| if (isStreaming || messages.length === 0) return; | |
| // Verify the last message is from the assistant | |
| const lastMsg = messages[messages.length - 1]; | |
| if (!lastMsg || lastMsg.role !== 'assistant') { | |
| return; | |
| } | |
| executeChatStream("Continue", false); | |
| } | |
| async function handleSend() { | |
| const text = userInputEl.value.trim(); | |
| if (!text || isStreaming) return; | |
| executeChatStream(text, false); | |
| } | |
| async function executeChatStream(text, isRegenerate = false) { | |
| if (isStreaming) return; | |
| isStreaming = true; | |
| setSendButton('stop'); | |
| updateHeader(); | |
| if (!isRegenerate) { | |
| const userMsg = { role: 'user', content: text }; | |
| messages.push(userMsg); | |
| userInputEl.value = ''; | |
| userInputEl.style.height = 'auto'; | |
| } | |
| renderMessages(); | |
| showTypingIndicator(); | |
| // History snapshot (excludes the just-added/current user turn). | |
| const history = messages | |
| .filter(m => ['user', 'assistant', 'system'].includes(m.role)) | |
| .slice(0, -1) | |
| .map(m => ({ role: m.role, content: String(m.content ?? '') })); | |
| abortController = new AbortController(); | |
| let placeholderCreated = false; | |
| try { | |
| const result = await streamGradioChat({ | |
| message: text, | |
| history, | |
| signal: abortController.signal, | |
| onFullText: (fullText) => { | |
| if (!placeholderCreated) { | |
| removeTypingIndicator(); | |
| createAssistantPlaceholder(); | |
| placeholderCreated = true; | |
| } | |
| setAssistantContent(fullText); | |
| } | |
| }); | |
| removeTypingIndicator(); | |
| // No tokens ever arrived β make a placeholder so we can show a fallback. | |
| if (!placeholderCreated) { | |
| createAssistantPlaceholder(); | |
| placeholderCreated = true; | |
| } | |
| clearAssistantStreamingUI(); | |
| if (activeAssistantMsg) { | |
| activeAssistantMsg.content = (activeAssistantMsg.content || '').trim(); | |
| if (!activeAssistantMsg.content) { | |
| activeAssistantMsg.content = "I'm not sure how to respond to that."; | |
| } | |
| } | |
| } catch (error) { | |
| removeTypingIndicator(); | |
| if (error.name === 'AbortError') { | |
| clearAssistantStreamingUI(); | |
| if (activeAssistantMsg) { | |
| activeAssistantMsg.content = (activeAssistantMsg.content || '').trim(); | |
| if (!activeAssistantMsg.content) { | |
| activeAssistantMsg.content = '(Generation stopped)'; | |
| } else { | |
| activeAssistantMsg.content += ' βΉ'; | |
| } | |
| } | |
| } else { | |
| console.error('Stream error:', error); | |
| showErrorToast(error.message || 'Unknown error'); | |
| clearAssistantStreamingUI(); | |
| if (activeAssistantMsg && !activeAssistantMsg.content.trim()) { | |
| activeAssistantMsg.content = 'β οΈ Sorry, something went wrong: ' + (error.message || 'Unknown error'); | |
| } else if (!activeAssistantMsg) { | |
| messages.push({ | |
| role: 'assistant', | |
| content: 'β οΈ Sorry, something went wrong: ' + (error.message || 'Unknown error') | |
| }); | |
| } | |
| } | |
| } finally { | |
| isStreaming = false; | |
| abortController = null; | |
| activeAssistantMsg = null; | |
| clearAssistantStreamingUI(); | |
| setSendButton('send'); | |
| renderMessages(); // This now renders with isStreaming = false, so the buttons WILL show! | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Textarea Auto-Resize | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| function autoResizeTextarea() { | |
| userInputEl.style.height = 'auto'; | |
| const newHeight = Math.min(userInputEl.scrollHeight, 200); | |
| userInputEl.style.height = newHeight + 'px'; | |
| } | |
| userInputEl.addEventListener('input', autoResizeTextarea); | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Enter to Send | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| userInputEl.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| onSendClick(); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Init | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| renderMessages(); | |
| setSendButton('send'); | |
| // ββ Loading screen: show the spinning logo briefly, then reveal the app ββ | |
| (function initLoadingScreen() { | |
| const loader = document.getElementById('loading-screen'); | |
| if (!loader) return; | |
| const MIN_VISIBLE_MS = 1400; | |
| const start = performance.now(); | |
| function hide() { | |
| const wait = Math.max(0, MIN_VISIBLE_MS - (performance.now() - start)); | |
| setTimeout(() => { | |
| loader.classList.add('hidden'); | |
| loader.addEventListener('transitionend', () => loader.remove(), { once: true }); | |
| }, wait); | |
| } | |
| if (document.readyState === 'complete') hide(); | |
| else window.addEventListener('load', hide); | |
| })(); | |
| const SVG_CLICK_SELECTOR = '.avatar.assistant svg, .empty-state svg'; | |
| function restartClassAnimation(el, className) { | |
| el.classList.remove(className); | |
| void el.getBoundingClientRect(); | |
| el.classList.add(className); | |
| } | |
| function animateSvg(svg) { | |
| // Whole-logo glow flash (CSS class restart). | |
| restartClassAnimation(svg, 'clicked'); | |
| // Staggered scale-punch rippling outer -> inner. We animate `transform`, | |
| // which is independent of the `rotate`/`scale` properties driving the idle | |
| // spin/breathe, so the layers keep spinning straight through the burst. | |
| const layers = svg.querySelectorAll( | |
| '.svg-layer-outer-1, .svg-layer-outer-2, .svg-layer-inner' | |
| ); | |
| layers.forEach((layer, i) => { | |
| layer.animate( | |
| [ | |
| { transform: 'scale(1)' }, | |
| { transform: 'scale(0.8)', offset: 0.35 }, | |
| { transform: 'scale(1.14)', offset: 0.7 }, | |
| { transform: 'scale(1)' } | |
| ], | |
| { | |
| duration: 420, | |
| delay: i * 80, | |
| easing: 'cubic-bezier(.2,.8,.2,1)', | |
| fill: 'none' | |
| } | |
| ); | |
| }); | |
| } | |
| document.addEventListener('pointerdown', (e) => { | |
| if (e.pointerType === 'mouse' && e.button !== 0) return; | |
| const target = e.target instanceof Element ? e.target : null; | |
| if (!target) return; | |
| const svg = target.closest(SVG_CLICK_SELECTOR); | |
| if (!svg) return; | |
| animateSvg(svg); | |
| }); | |
| </script> | |
| <script> | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Glass Shatter Effect (Shatter Edition) | |
| // ββββββββββββββββββββββββββββββββββββββββββββ | |
| // The app loads behind a pane of glass. The first click cracks it from | |
| // the impact point and blows the shards out of the screen. The π¨ button | |
| // in the corner replays the effect any time. | |
| (function glassShatter() { | |
| let pane = null; | |
| let breaking = false; | |
| function spawnPane(showHint) { | |
| if (pane) return; | |
| pane = document.createElement('div'); | |
| pane.id = 'glass-pane'; | |
| if (showHint) { | |
| const hint = document.createElement('div'); | |
| hint.className = 'glass-hint'; | |
| hint.textContent = 'Click anywhere to shatter'; | |
| pane.appendChild(hint); | |
| } | |
| document.body.appendChild(pane); | |
| requestAnimationFrame(() => requestAnimationFrame(() => pane.classList.add('on'))); | |
| pane.addEventListener('pointerdown', (e) => shatterAt(e.clientX, e.clientY)); | |
| } | |
| // Synthesized crash: a filtered noise burst plus a few glassy pings. | |
| function playShatterSound() { | |
| try { | |
| const Ctx = window.AudioContext || window.webkitAudioContext; | |
| if (!Ctx) return; | |
| const ctx = new Ctx(); | |
| const now = ctx.currentTime; | |
| const len = Math.floor(ctx.sampleRate * 0.45); | |
| const buf = ctx.createBuffer(1, len, ctx.sampleRate); | |
| const data = buf.getChannelData(0); | |
| for (let i = 0; i < len; i++) { | |
| data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.2); | |
| } | |
| const noise = ctx.createBufferSource(); | |
| noise.buffer = buf; | |
| const hp = ctx.createBiquadFilter(); | |
| hp.type = 'highpass'; | |
| hp.frequency.value = 2400; | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(0.18, now); | |
| g.gain.exponentialRampToValueAtTime(0.001, now + 0.5); | |
| noise.connect(hp).connect(g).connect(ctx.destination); | |
| noise.start(now); | |
| for (let i = 0; i < 5; i++) { | |
| const osc = ctx.createOscillator(); | |
| const og = ctx.createGain(); | |
| osc.type = 'sine'; | |
| osc.frequency.value = 1800 + Math.random() * 3200; | |
| const t = now + 0.03 + Math.random() * 0.22; | |
| og.gain.setValueAtTime(0.05, t); | |
| og.gain.exponentialRampToValueAtTime(0.0008, t + 0.3); | |
| osc.connect(og).connect(ctx.destination); | |
| osc.start(t); | |
| osc.stop(t + 0.32); | |
| } | |
| setTimeout(() => ctx.close(), 1500); | |
| } catch { /* sound is optional */ } | |
| } | |
| function shatterAt(cx, cy) { | |
| if (!pane || breaking) return; | |
| breaking = true; | |
| const hint = pane.querySelector('.glass-hint'); | |
| if (hint) hint.remove(); | |
| const W = window.innerWidth, H = window.innerHeight; | |
| const maxR = Math.max( | |
| Math.hypot(cx, cy), Math.hypot(W - cx, cy), | |
| Math.hypot(cx, H - cy), Math.hypot(W - cx, H - cy) | |
| ) * 1.12; | |
| // Crack web: jittered rays out from the impact + rings across them. | |
| // pts[i][j] = vertex where ray i meets ring j (j = 0 is the impact). | |
| const nRays = 10 + Math.floor(Math.random() * 3); | |
| const ringFracs = [0.14, 0.34, 0.62, 1.0]; | |
| const pts = []; | |
| for (let i = 0; i < nRays; i++) { | |
| const baseAngle = (i / nRays) * Math.PI * 2 + (Math.random() - 0.5) * 0.45; | |
| const row = [[cx, cy]]; | |
| for (let j = 0; j < ringFracs.length; j++) { | |
| const a = baseAngle + (Math.random() - 0.5) * 0.18; | |
| const r = maxR * ringFracs[j] * (0.85 + Math.random() * 0.3); | |
| row.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); | |
| } | |
| pts.push(row); | |
| } | |
| playShatterSound(); | |
| // Impact flash. | |
| const flash = document.createElement('div'); | |
| flash.className = 'glass-flash'; | |
| flash.style.background = `radial-gradient(circle at ${cx}px ${cy}px, rgba(255,255,255,0.85), rgba(255,255,255,0.25) 18%, transparent 45%)`; | |
| document.body.appendChild(flash); | |
| flash.animate( | |
| [{ opacity: 1 }, { opacity: 0 }], | |
| { duration: 320, easing: 'ease-out' } | |
| ).onfinish = () => flash.remove(); | |
| // Screen shake. | |
| const main = document.getElementById('main'); | |
| if (main) { | |
| main.animate( | |
| [0, 1, 2, 3, 4, 5].map(k => ({ | |
| transform: (k === 0 || k === 5) ? 'translate(0,0)' : | |
| `translate(${(Math.random() * 14 - 7).toFixed(1)}px, ${(Math.random() * 14 - 7).toFixed(1)}px)` | |
| })), | |
| { duration: 340, easing: 'ease-out' } | |
| ); | |
| } | |
| // Draw the cracks (rays + inner rings) with a quick dash reveal. | |
| const SVG_NS = 'http://www.w3.org/2000/svg'; | |
| const svg = document.createElementNS(SVG_NS, 'svg'); | |
| svg.setAttribute('width', W); | |
| svg.setAttribute('height', H); | |
| svg.style.cssText = 'position:fixed;inset:0;z-index:1502;pointer-events:none;'; | |
| const polylines = []; | |
| for (let i = 0; i < nRays; i++) { | |
| polylines.push(pts[i].map(p => p.join(',')).join(' ')); | |
| } | |
| for (let j = 1; j < ringFracs.length; j++) { | |
| const ring = []; | |
| for (let i = 0; i <= nRays; i++) ring.push(pts[i % nRays][j].join(',')); | |
| polylines.push(ring.join(' ')); | |
| } | |
| for (const ptsStr of polylines) { | |
| const pl = document.createElementNS(SVG_NS, 'polyline'); | |
| pl.setAttribute('points', ptsStr); | |
| pl.setAttribute('fill', 'none'); | |
| pl.setAttribute('stroke', 'rgba(255,255,255,0.85)'); | |
| pl.setAttribute('stroke-width', (0.8 + Math.random() * 1.4).toFixed(2)); | |
| svg.appendChild(pl); | |
| } | |
| document.body.appendChild(svg); | |
| for (const pl of svg.children) { | |
| const len = pl.getTotalLength(); | |
| pl.style.strokeDasharray = len; | |
| pl.style.strokeDashoffset = len; | |
| pl.animate( | |
| [{ strokeDashoffset: len }, { strokeDashoffset: 0 }], | |
| { duration: 160 + Math.random() * 120, easing: 'ease-out', fill: 'forwards' } | |
| ); | |
| } | |
| // After the cracks land, release the shards. | |
| setTimeout(() => { | |
| pane.classList.add('broken'); | |
| svg.animate( | |
| [{ opacity: 1 }, { opacity: 0 }], | |
| { duration: 250, fill: 'forwards' } | |
| ).onfinish = () => svg.remove(); | |
| const shards = []; | |
| for (let i = 0; i < nRays; i++) { | |
| const next = (i + 1) % nRays; | |
| for (let j = 0; j < ringFracs.length; j++) { | |
| const poly = j === 0 | |
| ? [pts[i][0], pts[i][1], pts[next][1]] | |
| : [pts[i][j], pts[i][j + 1], pts[next][j + 1], pts[next][j]]; | |
| shards.push({ poly, ring: j }); | |
| } | |
| } | |
| let pending = shards.length; | |
| for (const s of shards) { | |
| const el = document.createElement('div'); | |
| el.className = 'glass-shard'; | |
| const sheenAngle = Math.floor(Math.random() * 360); | |
| const a = 0.05 + Math.random() * 0.1; | |
| el.style.background = `linear-gradient(${sheenAngle}deg, rgba(255,255,255,${(a + 0.08).toFixed(3)}), rgba(190,205,255,${a.toFixed(3)}) 45%, rgba(255,255,255,${(a + 0.05).toFixed(3)}))`; | |
| el.style.clipPath = `polygon(${s.poly.map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(',')})`; | |
| document.body.appendChild(el); | |
| const centX = s.poly.reduce((sum, p) => sum + p[0], 0) / s.poly.length; | |
| const centY = s.poly.reduce((sum, p) => sum + p[1], 0) / s.poly.length; | |
| const d = Math.hypot(centX - cx, centY - cy) || 1; | |
| const dirX = (centX - cx) / d, dirY = (centY - cy) / d; | |
| const fly = 90 + Math.random() * 240; | |
| const fall = H * (0.5 + Math.random() * 0.7); | |
| const rot = Math.random() * 140 - 70; | |
| el.animate( | |
| [ | |
| { transform: 'translate(0,0) rotate(0deg)', opacity: 1 }, | |
| { transform: `translate(${(dirX * fly * 0.6).toFixed(1)}px, ${(dirY * fly * 0.6).toFixed(1)}px) rotate(${(rot * 0.3).toFixed(1)}deg)`, opacity: 1, offset: 0.25 }, | |
| { transform: `translate(${(dirX * fly).toFixed(1)}px, ${(dirY * fly + fall).toFixed(1)}px) rotate(${rot.toFixed(1)}deg)`, opacity: 0 } | |
| ], | |
| { | |
| duration: 800 + Math.random() * 500, | |
| delay: s.ring * 55 + Math.random() * 90, | |
| easing: 'cubic-bezier(.4, .05, .8, .6)', | |
| fill: 'forwards' | |
| } | |
| ).onfinish = () => { | |
| el.remove(); | |
| if (--pending === 0) cleanup(); | |
| }; | |
| } | |
| }, 300); | |
| function cleanup() { | |
| if (pane) { | |
| pane.remove(); | |
| pane = null; | |
| } | |
| breaking = false; | |
| } | |
| } | |
| // Replay button: rebuild the pane, then break it from a random spot. | |
| const btn = document.createElement('button'); | |
| btn.id = 'shatter-btn'; | |
| btn.title = 'Shatter the screen'; | |
| btn.setAttribute('aria-label', 'Shatter the screen'); | |
| btn.textContent = 'π¨'; | |
| document.body.appendChild(btn); | |
| btn.addEventListener('click', () => { | |
| if (breaking) return; | |
| if (pane) { | |
| shatterAt(window.innerWidth / 2, window.innerHeight / 2); | |
| return; | |
| } | |
| spawnPane(false); | |
| setTimeout(() => { | |
| shatterAt( | |
| window.innerWidth * (0.3 + Math.random() * 0.4), | |
| window.innerHeight * (0.25 + Math.random() * 0.4) | |
| ); | |
| }, 550); | |
| }); | |
| // First visit: the app loads behind a pane of glass β break through it. | |
| spawnPane(true); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |