Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>GLM-OCR β Self-Hosted Document OCR</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"/> | |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --ink: #0f0e0d; | |
| --paper: #f5f0e8; | |
| --warm: #ede8dc; | |
| --border: #d4cfc3; | |
| --muted: #8f8880; | |
| --accent: #c94a1f; | |
| --green: #1a6b4a; | |
| --mono: 'IBM Plex Mono', monospace; | |
| --serif: 'DM Serif Display', serif; | |
| --sans: 'DM Sans', sans-serif; | |
| } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| background: var(--paper); | |
| color: var(--ink); | |
| font-family: var(--sans); | |
| min-height: 100vh; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background-image: radial-gradient(circle, rgba(0,0,0,0.055) 1px, transparent 1px); | |
| background-size: 18px 18px; | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .page { position: relative; z-index: 1; } | |
| /* ββ MASTHEAD ββ */ | |
| .masthead { | |
| border-bottom: 3px solid var(--ink); | |
| padding: 0 48px; | |
| display: grid; | |
| grid-template-columns: 1fr auto 1fr; | |
| align-items: center; | |
| min-height: 68px; | |
| gap: 16px; | |
| } | |
| .masthead-left { | |
| font-family: var(--mono); | |
| font-size: 0.62rem; | |
| color: var(--muted); | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .masthead-center { | |
| font-family: var(--serif); | |
| font-size: 1.3rem; | |
| white-space: nowrap; | |
| } | |
| .masthead-right { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 6px; | |
| } | |
| .pill { | |
| font-family: var(--mono); | |
| font-size: 0.6rem; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| padding: 4px 9px; | |
| border-radius: 2px; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .pill.live { border-color: var(--green); color: var(--green); } | |
| .status-dot { | |
| width: 6px; height: 6px; | |
| border-radius: 50%; | |
| background: var(--muted); | |
| flex-shrink: 0; | |
| } | |
| .status-dot.ok { background: var(--green); } | |
| .status-dot.err { background: var(--accent); } | |
| .status-dot.pulse { | |
| animation: blink 1.2s ease-in-out infinite; | |
| } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} } | |
| /* ββ HERO ββ */ | |
| .hero { | |
| padding: 64px 48px 48px; | |
| border-bottom: 1px solid var(--border); | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 48px; | |
| align-items: end; | |
| } | |
| .hero-headline { | |
| font-family: var(--serif); | |
| font-size: clamp(2.8rem, 5.5vw, 5rem); | |
| line-height: 1.02; | |
| letter-spacing: -0.02em; | |
| } | |
| .hero-headline em { font-style: italic; color: var(--accent); } | |
| .hero-right { display: flex; flex-direction: column; gap: 20px; } | |
| .hero-desc { | |
| font-size: 0.88rem; | |
| color: var(--muted); | |
| line-height: 1.75; | |
| } | |
| .hero-stats { | |
| display: flex; | |
| gap: 24px; | |
| flex-wrap: wrap; | |
| } | |
| .stat { display: flex; flex-direction: column; gap: 2px; } | |
| .stat strong { | |
| font-family: var(--serif); | |
| font-size: 1.5rem; | |
| color: var(--accent); | |
| } | |
| .stat span { | |
| font-family: var(--mono); | |
| font-size: 0.58rem; | |
| color: var(--muted); | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| } | |
| /* ββ MAIN ββ */ | |
| .main { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .col { padding: 36px 48px; } | |
| .col + .col { border-left: 1px solid var(--border); } | |
| .col-label { | |
| font-family: var(--mono); | |
| font-size: 0.62rem; | |
| color: var(--muted); | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .col-label::after { content: ''; flex: 1; height: 1px; background: var(--border); } | |
| /* ββ DROPZONE ββ */ | |
| #dropzone { | |
| border: 2px dashed var(--border); | |
| border-radius: 4px; | |
| min-height: 240px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #dropzone:hover, #dropzone.over { | |
| border-color: var(--accent); | |
| background: rgba(201,74,31,0.04); | |
| } | |
| #dropzone.over::after { | |
| content: 'Drop!'; | |
| position: absolute; inset: 0; | |
| background: rgba(201,74,31,0.08); | |
| display: grid; place-items: center; | |
| font-family: var(--serif); | |
| font-size: 2rem; | |
| color: var(--accent); | |
| } | |
| .dz-icon { font-size: 2.2rem; } | |
| .dz-label strong { display: block; font-weight: 500; font-size: 0.88rem; margin-bottom: 5px; text-align:center; } | |
| .dz-label span { font-family: var(--mono); font-size: 0.64rem; color: var(--muted); } | |
| #file-input { display: none; } | |
| /* Preview */ | |
| #preview-wrap { display: none; } | |
| #preview-wrap.active { display: block; } | |
| #preview-img { | |
| width: 100%; max-height: 240px; | |
| object-fit: contain; | |
| border: 1px solid var(--border); | |
| border-radius: 2px; | |
| background: var(--warm); | |
| } | |
| .file-meta { | |
| margin-top: 8px; | |
| font-family: var(--mono); | |
| font-size: 0.65rem; | |
| color: var(--muted); | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| /* ββ MODE ββ */ | |
| .mode-row { | |
| margin: 18px 0 14px; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .mode-btn { | |
| font-family: var(--mono); | |
| font-size: 0.67rem; | |
| letter-spacing: 0.04em; | |
| padding: 9px 14px; | |
| border: 1px solid var(--border); | |
| background: transparent; | |
| color: var(--muted); | |
| cursor: pointer; | |
| border-radius: 2px; | |
| transition: all 0.15s; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| flex: 1; | |
| text-align: left; | |
| } | |
| .mode-btn .mode-name { font-weight: 700; color: var(--ink); } | |
| .mode-btn .mode-desc { font-size: 0.58rem; } | |
| .mode-btn.selected { | |
| background: var(--ink); | |
| border-color: var(--ink); | |
| color: var(--paper); | |
| } | |
| .mode-btn.selected .mode-name { color: var(--paper); } | |
| /* ββ RUN BTN ββ */ | |
| .run-btn { | |
| width: 100%; | |
| padding: 14px; | |
| background: var(--accent); | |
| color: white; | |
| border: none; | |
| border-radius: 2px; | |
| font-family: var(--serif); | |
| font-size: 1.05rem; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| .run-btn:hover:not(:disabled) { background: #b53d15; } | |
| .run-btn:disabled { opacity: 0.35; cursor: not-allowed; } | |
| .clear-link { | |
| font-family: var(--mono); | |
| font-size: 0.64rem; | |
| color: var(--muted); | |
| text-decoration: underline; | |
| cursor: pointer; | |
| display: none; | |
| margin-top: 10px; | |
| text-align: center; | |
| } | |
| /* ββ OUTPUT ββ */ | |
| .output-area { | |
| min-height: 300px; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| #out-placeholder { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| border: 1px dashed var(--border); | |
| border-radius: 2px; | |
| } | |
| #out-placeholder .ph { font-size: 2rem; opacity: 0.3; } | |
| #out-placeholder p { font-family: var(--mono); font-size: 0.68rem; color: var(--muted); text-align: center; line-height: 1.9; } | |
| /* Loading */ | |
| #out-loading { display: none; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 16px; } | |
| #out-loading.active { display: flex; } | |
| .scan-bar-wrap { width: 160px; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; } | |
| .scan-bar { height: 100%; background: var(--accent); border-radius: 2px; animation: scan 1.4s ease-in-out infinite; } | |
| @keyframes scan { 0%{transform:translateX(-100%)} 50%{transform:translateX(0)} 100%{transform:translateX(100%)} } | |
| .scan-label { font-family: var(--mono); font-size: 0.68rem; color: var(--muted); animation: blink 1.4s ease-in-out infinite; } | |
| /* Error */ | |
| #out-error { display: none; background: #fff0f0; border: 1px solid rgba(201,74,31,0.3); border-radius: 2px; padding: 16px; font-family: var(--mono); font-size: 0.72rem; color: var(--accent); line-height: 1.7; } | |
| #out-error.active { display: block; } | |
| /* Result */ | |
| #out-result { display: none; flex-direction: column; gap: 10px; } | |
| #out-result.active { display: flex; } | |
| #result-meta { display: flex; gap: 14px; flex-wrap: wrap; } | |
| .chip { font-family: var(--mono); font-size: 0.62rem; color: var(--muted); } | |
| .chip strong { color: var(--green); } | |
| #result-content { | |
| background: var(--warm); | |
| border: 1px solid var(--border); | |
| border-radius: 2px; | |
| padding: 18px; | |
| font-family: var(--mono); | |
| font-size: 0.78rem; | |
| line-height: 1.9; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| max-height: 340px; | |
| overflow-y: auto; | |
| flex: 1; | |
| } | |
| .result-actions { display: flex; gap: 8px; } | |
| .action-btn { | |
| font-family: var(--mono); | |
| font-size: 0.65rem; | |
| letter-spacing: 0.05em; | |
| padding: 9px 14px; | |
| border: 1px solid var(--border); | |
| background: transparent; | |
| color: var(--ink); | |
| cursor: pointer; | |
| border-radius: 2px; | |
| transition: border-color 0.15s; | |
| flex: 1; | |
| } | |
| .action-btn:hover { border-color: var(--ink); } | |
| /* ββ STATUS BAR ββ */ | |
| .statusbar { | |
| border-top: 3px double var(--border); | |
| padding: 14px 48px; | |
| display: flex; | |
| gap: 32px; | |
| flex-wrap: wrap; | |
| font-family: var(--mono); | |
| font-size: 0.64rem; | |
| color: var(--muted); | |
| } | |
| .statusbar strong { color: var(--green); } | |
| footer { | |
| border-top: 1px solid var(--border); | |
| padding: 18px 48px; | |
| display: flex; | |
| justify-content: space-between; | |
| font-family: var(--mono); | |
| font-size: 0.62rem; | |
| color: var(--muted); | |
| } | |
| footer a { color: var(--ink); text-decoration: underline; } | |
| /* ββ TOAST ββ */ | |
| .toast { | |
| position: fixed; | |
| bottom: 24px; right: 24px; | |
| background: var(--ink); | |
| color: var(--paper); | |
| font-family: var(--mono); | |
| font-size: 0.7rem; | |
| padding: 11px 18px; | |
| border-radius: 2px; | |
| transform: translateY(60px); | |
| opacity: 0; | |
| transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); | |
| z-index: 999; | |
| } | |
| .toast.show { transform: translateY(0); opacity: 1; } | |
| @keyframes fadeUp { from{opacity:0;transform:translateY(16px)} to{opacity:1;transform:translateY(0)} } | |
| .masthead { animation: fadeUp 0.5s ease both; } | |
| .hero { animation: fadeUp 0.5s 0.08s ease both; } | |
| .main { animation: fadeUp 0.5s 0.16s ease both; } | |
| @media (max-width: 820px) { | |
| .masthead, .hero, .col, .statusbar, footer { padding-left: 24px; padding-right: 24px; } | |
| .hero, .main { grid-template-columns: 1fr; } | |
| .col + .col { border-left: none; border-top: 1px solid var(--border); } | |
| } | |
| </style> | |
| <!-- Google Analytics --> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-B840VLLZWQ"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag(){dataLayer.push(arguments);} | |
| gtag('js', new Date()); | |
| gtag('config', 'G-B840VLLZWQ'); | |
| </script> | |
| </head> | |
| <body> | |
| <div class="page"> | |
| <!-- MASTHEAD --> | |
| <div class="masthead"> | |
| <div class="masthead-left">zai-org/GLM-OCR Β· 0.9B params</div> | |
| <div class="masthead-center">GLM-OCR Engine</div> | |
| <div class="masthead-right"> | |
| <div class="pill" id="server-pill"> | |
| <div class="status-dot pulse" id="status-dot"></div> | |
| <span id="status-label">connectingβ¦</span> | |
| </div> | |
| <div class="pill live">self-hosted</div> | |
| </div> | |
| </div> | |
| <!-- HERO --> | |
| <section class="hero"> | |
| <div> | |
| <h1 class="hero-headline">GLM<br><em>Vision</em><br>OCR</h1> | |
| </div> | |
| <div class="hero-right"> | |
| <p class="hero-desc"> | |
| Self-hosted OCR powered by <strong>zai-org/GLM-OCR</strong> β a 0.9B vision-language model | |
| ranking #1 on OmniDocBench V1.5. Handles plain text, tables, math formulas, | |
| and structured document parsing. | |
| </p> | |
| <div class="hero-stats"> | |
| <div class="stat"><strong id="stat-count">0</strong><span>Processed</span></div> | |
| <div class="stat"><strong id="stat-words">0</strong><span>Words</span></div> | |
| <div class="stat"><strong id="stat-lat">β</strong><span>Avg Latency</span></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- MAIN --> | |
| <div class="main"> | |
| <!-- LEFT --> | |
| <div class="col"> | |
| <div class="col-label">01 Upload Image</div> | |
| <div id="dropzone"> | |
| <div class="dz-icon">πΌ</div> | |
| <div class="dz-label"> | |
| <strong>Drag & drop an image</strong> | |
| <span>PNG Β· JPG Β· WEBP Β· BMP Β· TIFF Β· Max 20 MB</span> | |
| </div> | |
| <input type="file" id="file-input" accept="image/*"/> | |
| </div> | |
| <div id="preview-wrap"> | |
| <img id="preview-img" src="" alt="Preview"/> | |
| <div class="file-meta"> | |
| <span id="file-name"></span> | |
| <span id="file-size"></span> | |
| </div> | |
| </div> | |
| <!-- Mode selector --> | |
| <div class="mode-row"> | |
| <button class="mode-btn selected" data-mode="recognize"> | |
| <span class="mode-name">recognize</span> | |
| <span class="mode-desc">Plain text Β· preserves layout</span> | |
| </button> | |
| <button class="mode-btn" data-mode="parse"> | |
| <span class="mode-name">parse</span> | |
| <span class="mode-desc">Structured markdown output</span> | |
| </button> | |
| </div> | |
| <button class="run-btn" id="run-btn" disabled>β‘ Run GLM-OCR</button> | |
| <div class="clear-link" id="clear-link">Clear image</div> | |
| </div> | |
| <!-- RIGHT --> | |
| <div class="col"> | |
| <div class="col-label">02 Extracted Text</div> | |
| <div class="output-area"> | |
| <div id="out-placeholder"> | |
| <div class="ph">π</div> | |
| <p>Upload an image and click<br>"Run GLM-OCR" to begin.</p> | |
| </div> | |
| <div id="out-loading"> | |
| <div class="scan-bar-wrap"><div class="scan-bar"></div></div> | |
| <div class="scan-label" id="loading-label">Initialisingβ¦</div> | |
| </div> | |
| <div id="out-error"></div> | |
| <div id="out-result"> | |
| <div id="result-meta"></div> | |
| <div id="result-content"></div> | |
| <div class="result-actions"> | |
| <button class="action-btn" id="copy-btn">Copy text</button> | |
| <button class="action-btn" id="dl-btn">Download .txt</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- STATUS BAR --> | |
| <div class="statusbar"> | |
| <span>Model: <strong id="sb-model">β</strong></span> | |
| <span>Device: <strong id="sb-device">β</strong></span> | |
| <span>Uptime: <strong id="sb-uptime">β</strong></span> | |
| <span>Errors: <strong id="sb-errors">β</strong></span> | |
| </div> | |
| <footer> | |
| <span>GLM-OCR Β· <a href="https://arxiv.org/abs/2603.10910" target="_blank">Paper β</a> Β· <a href="https://huggingface.co/zai-org/GLM-OCR" target="_blank">HuggingFace β</a></span> | |
| <span>Self-hosted Β· No data leaves your server Β· CS Portfolio Project</span> | |
| </footer> | |
| </div> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| const API = ''; | |
| let selectedMode = 'recognize'; | |
| let imageFile = null; | |
| // Elements | |
| const dropzone = document.getElementById('dropzone'); | |
| const fileInput = document.getElementById('file-input'); | |
| const previewWrap= document.getElementById('preview-wrap'); | |
| const previewImg = document.getElementById('preview-img'); | |
| const runBtn = document.getElementById('run-btn'); | |
| const clearLink = document.getElementById('clear-link'); | |
| const outPlaceholder = document.getElementById('out-placeholder'); | |
| const outLoading = document.getElementById('out-loading'); | |
| const outError = document.getElementById('out-error'); | |
| const outResult = document.getElementById('out-result'); | |
| const loadingLabel = document.getElementById('loading-label'); | |
| const resultMeta = document.getElementById('result-meta'); | |
| const resultContent = document.getElementById('result-content'); | |
| // ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function pollHealth() { | |
| try { | |
| const r = await fetch(`${API}/health`); | |
| const data = await r.json(); | |
| const dot = document.getElementById('status-dot'); | |
| const lbl = document.getElementById('status-label'); | |
| if (data.status === 'ok') { | |
| dot.className = 'status-dot ok'; | |
| lbl.textContent = 'model ready'; | |
| document.getElementById('sb-model').textContent = data.model.model_id?.split('/')[1] || 'β'; | |
| document.getElementById('sb-device').textContent = data.model.device || 'β'; | |
| } else { | |
| dot.className = 'status-dot pulse'; | |
| lbl.textContent = 'loading modelβ¦'; | |
| setTimeout(pollHealth, 3000); | |
| } | |
| } catch { | |
| document.getElementById('status-dot').className = 'status-dot err'; | |
| document.getElementById('status-label').textContent = 'server offline'; | |
| } | |
| } | |
| async function pollMetrics() { | |
| try { | |
| const r = await fetch(`${API}/metrics`); | |
| const data = await r.json(); | |
| document.getElementById('stat-count').textContent = data.total_requests; | |
| document.getElementById('stat-words').textContent = data.total_words_extracted.toLocaleString(); | |
| document.getElementById('stat-lat').textContent = data.avg_latency_ms | |
| ? `${(data.avg_latency_ms / 1000).toFixed(1)}s` : 'β'; | |
| document.getElementById('sb-uptime').textContent = | |
| `${Math.floor(data.uptime_seconds / 60)}m ${(data.uptime_seconds % 60) | 0}s`; | |
| document.getElementById('sb-errors').textContent = data.error_count; | |
| } catch {} | |
| } | |
| pollHealth(); | |
| pollMetrics(); | |
| setInterval(pollMetrics, 5000); | |
| // ββ Mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.querySelectorAll('.mode-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('selected')); | |
| btn.classList.add('selected'); | |
| selectedMode = btn.dataset.mode; | |
| }); | |
| }); | |
| // ββ File ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadFile(file) { | |
| if (!file || !file.type.startsWith('image/')) return; | |
| imageFile = file; | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| previewImg.src = e.target.result; | |
| document.getElementById('file-name').textContent = file.name; | |
| document.getElementById('file-size').textContent = `${(file.size/1024).toFixed(1)} KB`; | |
| dropzone.style.display = 'none'; | |
| previewWrap.classList.add('active'); | |
| clearLink.style.display = 'block'; | |
| runBtn.disabled = false; | |
| resetOutput(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| dropzone.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', e => loadFile(e.target.files[0])); | |
| dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('over'); }); | |
| dropzone.addEventListener('dragleave', () => dropzone.classList.remove('over')); | |
| dropzone.addEventListener('drop', e => { | |
| e.preventDefault(); dropzone.classList.remove('over'); loadFile(e.dataTransfer.files[0]); | |
| }); | |
| clearLink.addEventListener('click', () => { | |
| imageFile = null; fileInput.value = ''; | |
| previewWrap.classList.remove('active'); | |
| dropzone.style.display = ''; | |
| clearLink.style.display = 'none'; | |
| runBtn.disabled = true; | |
| resetOutput(); | |
| }); | |
| // ββ Output state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function resetOutput() { | |
| outPlaceholder.style.display = ''; | |
| outLoading.classList.remove('active'); | |
| outError.classList.remove('active'); | |
| outResult.classList.remove('active'); | |
| } | |
| function showLoading(msg) { | |
| outPlaceholder.style.display = 'none'; | |
| loadingLabel.textContent = msg || 'Running GLM-OCRβ¦'; | |
| outLoading.classList.add('active'); | |
| outError.classList.remove('active'); | |
| outResult.classList.remove('active'); | |
| } | |
| function showError(msg) { | |
| outLoading.classList.remove('active'); | |
| outError.classList.add('active'); | |
| outError.textContent = `β ${msg}`; | |
| } | |
| function showResult(data) { | |
| outLoading.classList.remove('active'); | |
| outResult.classList.add('active'); | |
| resultMeta.innerHTML = [ | |
| `<span class="chip">words: <strong>${data.word_count}</strong></span>`, | |
| `<span class="chip">chars: <strong>${data.char_count}</strong></span>`, | |
| `<span class="chip">latency: <strong>${(data.latency_ms/1000).toFixed(2)}s</strong></span>`, | |
| `<span class="chip">device: <strong>${data.device}</strong></span>`, | |
| `<span class="chip">mode: <strong>${data.mode}</strong></span>`, | |
| ].join(''); | |
| resultContent.textContent = data.text || '[No text detected]'; | |
| pollMetrics(); | |
| } | |
| // ββ Loading messages ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const msgs = ['Running GLM-OCRβ¦', 'Encoding imageβ¦', 'Decoding tokensβ¦', 'Assembling outputβ¦']; | |
| let msgTimer = null; | |
| function startLoadingAnim() { | |
| let i = 0; | |
| showLoading(msgs[0]); | |
| msgTimer = setInterval(() => { i = (i+1) % msgs.length; loadingLabel.textContent = msgs[i]; }, 2000); | |
| } | |
| function stopLoadingAnim() { clearInterval(msgTimer); } | |
| // ββ Run ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| runBtn.addEventListener('click', async () => { | |
| if (!imageFile) return; | |
| runBtn.disabled = true; | |
| startLoadingAnim(); | |
| const form = new FormData(); | |
| form.append('file', imageFile); | |
| form.append('mode', selectedMode); | |
| try { | |
| const r = await fetch(`${API}/ocr`, { method: 'POST', body: form }); | |
| const data = await r.json(); | |
| if (!r.ok) throw new Error(data.detail || `Error ${r.status}`); | |
| showResult(data); | |
| } catch (err) { | |
| showError(err.message); | |
| } finally { | |
| stopLoadingAnim(); | |
| runBtn.disabled = false; | |
| } | |
| }); | |
| // ββ Copy βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('copy-btn').addEventListener('click', async () => { | |
| try { await navigator.clipboard.writeText(resultContent.textContent); toast('Copied!'); } | |
| catch { toast('Select text manually.'); } | |
| }); | |
| // ββ Download βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('dl-btn').addEventListener('click', () => { | |
| const blob = new Blob([resultContent.textContent], { type: 'text/plain' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `glm-ocr-${Date.now()}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }); | |
| // ββ Toast βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function toast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.classList.add('show'); | |
| setTimeout(() => t.classList.remove('show'), 2200); | |
| } | |
| </script> | |
| </body> | |
| </html> |