Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>LaTeX Notepad</title> | |
| <!-- ──────── MathJax ──────── --> | |
| <script> | |
| window.MathJax = { | |
| tex: { | |
| inlineMath: [['$', '$'], ['\\(', '\\)']], | |
| displayMath: [['$$', '$$'], ['\\[', '\\]']], | |
| processEscapes: true, | |
| processEnvironments: true, | |
| }, | |
| options: { skipHtmlTags: ['script','noscript','style','textarea','pre'] } | |
| }; | |
| </script> | |
| <script | |
| id="MathJax-script" | |
| async | |
| src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" | |
| ></script> | |
| <!-- marked.js for Markdown --> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <!-- Google fonts --> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Crimson+Text:wght@400;600&family=Libre+Baskerville:wght@400;700&family=PT+Mono&display=swap" | |
| rel="stylesheet" | |
| /> | |
| <style> | |
| /* ───────────────────────────────────────── | |
| COLOR SYSTEM (light / dark via variables) | |
| ───────────────────────────────────────── */ | |
| :root { | |
| --desk-bg: #fbf9f5; | |
| --desk-dot: #e2dccd; | |
| --paper-bg: #fffefa; | |
| --paper-text: #222; | |
| --shadow: rgba(0,0,0,.35); | |
| --line: rgba(201,190,170,.18); | |
| --perforation: #d0c6b7; | |
| --tbl-border: #d7cebf; | |
| --blockquote-bg: #fbf8f1; | |
| --blockquote-bar: #ccbfae; | |
| --code-bg: #f4f2ec; | |
| --code-border: #e6e0d2; | |
| --inline-code-bg: #f2efe8; | |
| /* copy button theme vars */ | |
| --copy-bg: var(--code-bg); | |
| --copy-border: var(--code-border); | |
| --copy-color: var(--paper-text); | |
| --copy-hover-bg: var(--blockquote-bg); | |
| --copy-hover-border: var(--blockquote-bar); | |
| --copy-success-bg: #4a5f4a; | |
| --copy-success-color:#fff; | |
| } | |
| body.dark { | |
| --desk-bg: #2c2a27; | |
| --desk-dot: #3a3733; | |
| --paper-bg: #302e2b; | |
| --paper-text: #e9e7e2; | |
| --shadow: rgba(0,0,0,.55); | |
| --line: rgba(110,103,94,.28); | |
| --perforation: #6d6456; | |
| --tbl-border: #555048; | |
| --blockquote-bg: #38332e; | |
| --blockquote-bar: #6a604e; | |
| --code-bg: #3a3530; | |
| --code-border: #514b42; | |
| --inline-code-bg: #4a443d; | |
| /* dark theme copy button vars */ | |
| --copy-bg: var(--code-bg); | |
| --copy-border: var(--code-border); | |
| --copy-color: var(--paper-text); | |
| --copy-hover-bg: #4a443d; /* Darker gray for hover */ | |
| --copy-hover-border:#6a604e; /* Darker border for hover */ | |
| --copy-success-bg: #4a5f4a; /* Consistent success color */ | |
| --copy-success-color:#fff; | |
| } | |
| /* ───────────────────────────────────────── | |
| GLOBAL "DESK" BACKGROUND | |
| ───────────────────────────────────────── */ | |
| html,body{height:100%} | |
| body{ | |
| margin:0; | |
| background: var(--desk-bg); | |
| background-image: radial-gradient(var(--desk-dot) 1px,transparent 1px); | |
| background-size:14px 14px; | |
| font-family:'Crimson Text','Times New Roman',serif; | |
| color:var(--paper-text); | |
| line-height:1.8; | |
| -webkit-font-smoothing:antialiased; | |
| } | |
| /* ───────────────────────────────────────── | |
| PAPER SHEET | |
| ───────────────────────────────────────── */ | |
| .container{ | |
| max-width:840px; | |
| margin:40px auto; | |
| padding:40px 60px 60px; | |
| background:var(--paper-bg); | |
| color:var(--paper-text); | |
| border:1px solid rgba(0,0,0,.05); | |
| border-radius:12px 12px 10px 10px; | |
| position:relative; | |
| box-shadow:0 18px 40px -22px var(--shadow), | |
| inset 0 2px 6px rgba(0,0,0,.06); | |
| background-size:160px 160px,100% 100%; | |
| } | |
| /* perforation holes */ | |
| .container::before{ | |
| content:''; | |
| position:absolute;top:26px;bottom:26px;left:30px;width:9px; | |
| background-image:radial-gradient(circle var(--perforation) 0%,var(--perforation) 2px,transparent 3px); | |
| background-size:9px 28px; | |
| background-repeat:repeat-y; | |
| pointer-events:none; | |
| } | |
| /* curled corner */ | |
| .container::after{ | |
| content:'';position:absolute;top:0;right:0;width:110px;height:110px; | |
| background: | |
| linear-gradient(135deg,rgba(0,0,0,.08) 0%,rgba(0,0,0,0) 42%), | |
| linear-gradient(135deg,var(--paper-bg) 0%,var(--paper-bg) 50%,rgba(255,255,255,0) 51%); | |
| background-size:100% 100%; | |
| border-bottom-left-radius:12px; | |
| transform:translate(1px,-1px); | |
| pointer-events:none; | |
| } | |
| /* ───────── theme toggle ───────── */ | |
| #themeToggle{ | |
| position:absolute;top:12px;right:14px; | |
| font-size:20px;background:none;border:none;cursor:pointer; | |
| transition:transform .25s; | |
| user-select:none; | |
| z-index:10; | |
| } | |
| #themeToggle:hover{transform:rotate(20deg)scale(1.15)} | |
| /* ───────── settings button ───────── */ | |
| #settingsBtn{ | |
| position:absolute;top:12px;right:50px; | |
| font-size:20px;background:none;border:none;cursor:pointer; | |
| transition:transform .25s; | |
| user-select:none; | |
| z-index:10; | |
| } | |
| #settingsBtn:hover{transform:rotate(20deg)scale(1.15)} | |
| /* ───────── header ───────── */ | |
| .header{text-align:center;margin-bottom:34px;padding-bottom:18px;border-bottom:1px solid rgba(0,0,0,.05)} | |
| h1{font-family:'Libre Baskerville',serif;margin:0;font-size:30px;letter-spacing:.5px} | |
| .subtitle{font-family:'PT Mono',monospace;font-size:14px;color:#666;margin-top:6px;letter-spacing:1px} | |
| /* ───────── content area ───────── */ | |
| #content{ | |
| min-height:520px;font-size:18px;position:relative; | |
| padding:10px 0 10px 26px;overflow-wrap:break-word;hyphens:auto; | |
| } | |
| #content::before{ | |
| content:'';position:absolute;inset:0; | |
| background:repeating-linear-gradient( | |
| 0deg, | |
| transparent,transparent 2.65em, | |
| var(--line) 2.65em,var(--line) 2.7em); | |
| pointer-events:none;z-index:1; | |
| } | |
| #content *{position:relative;z-index:2} | |
| .placeholder{color:#888;font-style:italic;text-align:center;padding:110px 20px;user-select:none} | |
| /* ───────── markdown tweaks ───────── */ | |
| blockquote{ | |
| border-left:4px solid var(--blockquote-bar); | |
| margin:20px 0;padding:15px 26px; | |
| background:var(--blockquote-bg);font-style:italic | |
| } | |
| code{font-family:'PT Mono',monospace;background:var(--inline-code-bg); | |
| padding:2px 6px;border-radius:3px;font-size:.9em} | |
| pre{background:var(--code-bg);padding:16px 20px;border:1px solid var(--code-border); | |
| border-radius:6px;overflow-x:auto;font-family:'PT Mono',monospace} | |
| /* lists */ | |
| ol{counter-reset:item;padding-left:0;list-style:none} | |
| ol>li{counter-increment:item;margin:.5em 0 .5em 2em} | |
| ol>li::before{content:counter(item)')';display:inline-block;width:1.5em;margin-left:-2em;text-align:right;font-weight:600} | |
| ol ol>li::before{content:counter(item,lower-alpha)')'} | |
| /* ───────── TABLES ───────── */ | |
| table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums;margin:1.2em 0} | |
| thead tr{border-bottom:1px solid var(--tbl-border)} | |
| tbody tr:not(:last-child){border-bottom:1px solid var(--tbl-border)} | |
| th,td{padding:.55em .8em;text-align:right} | |
| th{font-weight:600} | |
| /* ───────── processing badge ───────── */ | |
| .processing{ | |
| position:fixed;top:20px;right:20px;background:#333;color:#fff; | |
| padding:10px 20px;border-radius:6px;font-family:'PT Mono',monospace; | |
| font-size:14px;opacity:0;transition:opacity .25s;z-index:2000 | |
| } | |
| .processing.show{opacity:.9} | |
| /* ───────── settings modal ───────── */ | |
| .modal{ | |
| display:none; | |
| position:fixed;top:0;left:0;width:100%;height:100%; | |
| background:rgba(0,0,0,0.5);z-index:3000; | |
| } | |
| .modal.show{display:flex;align-items:center;justify-content:center} | |
| .modal-content{ | |
| background:var(--paper-bg); | |
| color:var(--paper-text); | |
| padding:30px; | |
| border-radius:8px; | |
| max-width:500px; | |
| width:90%; | |
| box-shadow:0 20px 60px rgba(0,0,0,0.3); | |
| } | |
| .modal h2{margin-top:0;font-family:'Libre Baskerville',serif} | |
| .modal label{ | |
| display:block; | |
| margin-top:15px; | |
| font-weight:600; | |
| font-size:14px; | |
| font-family:'PT Mono',monospace; | |
| } | |
| .modal input{ | |
| width:100%; | |
| padding:8px; | |
| margin-top:5px; | |
| border:1px solid var(--code-border); | |
| border-radius:4px; | |
| background:var(--code-bg); | |
| color:var(--paper-text); | |
| font-family:'PT Mono',monospace; | |
| font-size:13px; | |
| box-sizing: border-box; | |
| } | |
| .modal-buttons{ | |
| margin-top:20px; | |
| display:flex; | |
| gap:10px; | |
| justify-content:flex-end; | |
| } | |
| .modal button{ | |
| padding:8px 16px; | |
| border:none; | |
| border-radius:4px; | |
| cursor:pointer; | |
| font-family:'PT Mono',monospace; | |
| font-size:14px; | |
| } | |
| .btn-save{ | |
| background:#4a5f4a; | |
| color:#fff; | |
| } | |
| .btn-cancel{ | |
| background:var(--code-bg); | |
| color:var(--paper-text); | |
| border:1px solid var(--code-border); | |
| } | |
| .api-hint{ | |
| font-size:11px; | |
| color:#888; | |
| margin-top:3px; | |
| font-style:italic; | |
| } | |
| /* Styles for the lightweight streaming area */ | |
| .mono-stream{ | |
| font-family:'PT Mono',monospace; | |
| white-space:pre-wrap; | |
| background:var(--code-bg); | |
| border:1px solid var(--code-border); | |
| padding:12px;border-radius:6px; | |
| margin-top: 8px; /* Add some spacing */ | |
| } | |
| /* ───────── copy button styles ───────── */ | |
| .copy-btn { | |
| display: inline-flex; /* Use flex for alignment */ | |
| align-items: center; | |
| gap: 6px; /* Space between icon and text */ | |
| margin-left: 10px; | |
| padding: 4px 10px; | |
| background: var(--copy-bg); | |
| border: 1px solid var(--copy-border); | |
| color: var(--copy-color); | |
| border-radius: 6px; | |
| font-family: 'PT Mono', monospace; | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: all 0.18s ease; /* Smoother transition */ | |
| user-select: none; | |
| } | |
| .copy-btn:hover { | |
| background: var(--copy-hover-bg); | |
| border-color: var(--copy-hover-border); | |
| transform: translateY(-1px); /* Subtle lift effect */ | |
| } | |
| .copy-btn.copied { | |
| background: var(--copy-success-bg); | |
| border-color: var(--copy-success-bg); | |
| color: var(--copy-success-color); | |
| } | |
| /* header layout (updated to use span for title, avoid <p> spacing issues) */ | |
| .section-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; /* Space between title and button */ | |
| margin: 6px 0 8px; /* Adjust vertical spacing */ | |
| } | |
| .section-title { | |
| font-family:'Libre Baskerville',serif; | |
| font-weight:700; | |
| } | |
| .section-title::after { | |
| content: " :"; /* Add colon after the title */ | |
| opacity:.85; | |
| } | |
| /* Optional: container for rendered blocks in final view */ | |
| .rendered { | |
| margin-bottom: 20px; | |
| } | |
| /* responsive & print */ | |
| @media(max-width:768px){ | |
| .container{margin:20px 16px;padding:28px} | |
| #content{font-size:16px} | |
| h1{font-size:24px} | |
| } | |
| @media print{ | |
| body{background:#fff} | |
| .container{box-shadow:none;border:none} | |
| .header,.processing,.instructions,#themeToggle,#settingsBtn,.copy-btn{display:none} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <!-- theme icon --> | |
| <button id="themeToggle" title="Toggle dark / light">🌙</button> | |
| <!-- settings icon --> | |
| <button id="settingsBtn" title="API Settings">⚙️</button> | |
| <div class="header"> | |
| <h1>LaTeX Notepad</h1> | |
| <div class="subtitle">press ctrl+v anywhere to render</div> | |
| </div> | |
| <div id="content"> | |
| <div class="placeholder"> | |
| Press <kbd>Ctrl</kbd>+<kbd>V</kbd> (or <kbd>⌘</kbd>+<kbd>V</kbd>) to paste and render Markdown / LaTeX<br> | |
| <small style="font-size:14px;opacity:0.8">You can also paste images to OCR and solve them!</small> | |
| </div> | |
| </div> | |
| <div class="instructions" style="text-align:center;font-family:'PT Mono',monospace;font-size:14px;color:#666;font-style:italic;margin-top:22px;"> | |
| Tip: you can paste raw Markdown, TeX, or images – they will be processed instantly ✨ | |
| </div> | |
| </div> | |
| <div class="processing">Processing…</div> | |
| <!-- Settings Modal --> | |
| <div id="settingsModal" class="modal"> | |
| <div class="modal-content"> | |
| <h2>API Settings</h2> | |
| <label for="nebiusKey">Nebius API Key:</label> | |
| <input type="password" id="nebiusKey" placeholder="Enter your Nebius API key" autocomplete="off"> | |
| <div class="api-hint">Used for OCR image processing</div> | |
| <label for="cerebrasKey">Cerebras API Key:</label> | |
| <input type="password" id="cerebrasKey" placeholder="Enter your Cerebras API key" autocomplete="off"> | |
| <div class="api-hint">Used for solving questions</div> | |
| <div class="modal-buttons"> | |
| <button class="btn-cancel" onclick="closeSettings()">Cancel</button> | |
| <button class="btn-save" onclick="saveSettings()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /* ======= processing badge helpers ======= */ | |
| const content = document.getElementById('content'); | |
| const processingNode = document.querySelector('.processing'); | |
| function showProcessing(text = 'Processing…'){ | |
| processingNode.textContent = text; | |
| processingNode.classList.add('show'); | |
| } | |
| function hideProcessing(){ | |
| setTimeout(()=>processingNode.classList.remove('show'),300); | |
| } | |
| /* ======= Copy to clipboard helper ======= */ | |
| function copyToClipboard(text, button) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| const originalText = button.textContent; | |
| button.textContent = '✓ Copied'; | |
| button.classList.add('copied'); | |
| setTimeout(() => { | |
| button.textContent = originalText; | |
| button.classList.remove('copied'); | |
| }, 1500); /* Shorter timeout for feedback */ | |
| }).catch(err => { | |
| console.error('Failed to copy:', err); | |
| alert('Failed to copy to clipboard'); | |
| }); | |
| } | |
| /* ======= Markdown + LaTeX renderer ======= */ | |
| function renderMdLatex(text) { | |
| const store = []; | |
| const PL = i => `%%LATEX_${i}%%`; /* Placeholder for LaTeX */ | |
| let idx = 0; | |
| const keep = m => (store.push(m), PL(idx++)); /* Function to store and replace with placeholder */ | |
| // Protect LaTeX blocks before Markdown parsing | |
| text = (text || '') | |
| .replace(/\\\[[\s\S]*?\\\]/g, keep) // \[ ... \] | |
| .replace(/\$\$[\s\S]*?\$\$/g, keep) // $$ ... $$ | |
| .replace(/\\\([\s\S]*?\\\)/g, keep) // \( ... \) | |
| .replace(/\$([^\$\n]+?)\$/g, keep); // $ ... $ (inline math) | |
| let html = marked.parse(text || ''); // Parse Markdown | |
| // Re-insert LaTeX blocks | |
| store.forEach((latex, i) => { html = html.replaceAll(PL(i), latex); }); | |
| return html; | |
| } | |
| /* ======= processContent uses the new renderer ======= */ | |
| function processContent(text){ | |
| showProcessing(); | |
| content.innerHTML = renderMdLatex(text); /* Render with Markdown and LaTeX */ | |
| if (window.MathJax?.typesetPromise) { | |
| MathJax.typesetPromise([content]).then(hideProcessing) | |
| .catch(e => { console.error('MathJax error:', e); hideProcessing(); }); | |
| } else { | |
| hideProcessing(); /* Hide if MathJax is not available */ | |
| } | |
| } | |
| /* ======= Clean leading labels to avoid duplication ======= */ | |
| function normalizeSection(s) { | |
| if (!s) return ''; | |
| let out = s.trim(); | |
| // Remove common leading labels like "Question:", "Answer:", etc. | |
| // This regex is more robust and handles various casings and separators. | |
| out = out.replace(/^(?:\s*[-*_]*\s*)*(?:Question|Q|Problem|Prompt|Answer|Solution|Ans)\s*[:\-]?\s*/i, ''); | |
| // Additional specific cleanups for common markdown bolding patterns | |
| out = out.replace(/^\s*\*{0,2}Answer\*{0,2}\s*:\s*/i, ''); | |
| out = out.replace(/^\s*\*{0,2}Solution\*{0,2}\s*:\s*/i, ''); | |
| out = out.replace(/^\s*\*{0,2}Ans\*{0,2}\s*:\s*/i, ''); | |
| out = out.replace(/^\s*\*{0,2}Q\*{0,2}\s*:\s*/i, ''); | |
| out = out.replace(/^\s*\*{0,2}Question\*{0,2}\s*:\s*/i, ''); | |
| out = out.replace(/^\s*\*{0,2}Problem\*{0,2}\s*:\s*/i, ''); | |
| return out.trim(); /* Return cleaned string */ | |
| } | |
| /* ======= UI Helpers for Streaming (updated headers & copy buttons) ======= */ | |
| let currentQuestion = ''; | |
| let currentAnswer = ''; | |
| function beginStreamingUI(question){ | |
| currentQuestion = normalizeSection(question); /* Normalize question text */ | |
| currentAnswer = ''; /* Reset answer when starting a new question */ | |
| /* Set up the initial streaming UI with copy buttons */ | |
| content.innerHTML = ` | |
| <div> | |
| <div class="section-header"> | |
| <span class="section-title">Question</span> | |
| <button class="copy-btn" onclick="copyToClipboard(currentQuestion, this)">📋 Copy</button> | |
| </div> | |
| <div class="mono-stream" id="qStream"></div> | |
| <hr style="opacity:.35; margin: 20px 0;"> | |
| <div class="section-header"> | |
| <span class="section-title">Answer</span> | |
| <button class="copy-btn" onclick="copyToClipboard(currentAnswer, this)">📋 Copy</button> | |
| </div> | |
| <div class="mono-stream" id="aStream">(generating...)</div> | |
| </div>`; | |
| const qEl = document.getElementById('qStream'); | |
| const aEl = document.getElementById('aStream'); | |
| qEl.textContent = currentQuestion; /* Display the normalized question */ | |
| aEl.textContent = ''; /* Clear "(generating...)" initially */ | |
| return { qEl, aEl }; /* Return elements for updating */ | |
| } | |
| function finalizeStreaming(question, fullAnswer){ | |
| currentQuestion = normalizeSection(question); /* Normalize question text */ | |
| currentAnswer = normalizeSection(fullAnswer); /* Normalize answer text */ | |
| /* Build the final HTML structure with proper rendering and copy buttons */ | |
| content.innerHTML = ` | |
| <div class="section-header"> | |
| <span class="section-title">Question</span> | |
| <button class="copy-btn" onclick="copyToClipboard(currentQuestion, this)">📋 Copy</button> | |
| </div> | |
| <div id="qRender" class="rendered"></div> | |
| <hr style="opacity:.35; margin: 20px 0;"> | |
| <div class="section-header"> | |
| <span class="section-title">Answer</span> | |
| <button class="copy-btn" onclick="copyToClipboard(currentAnswer, this)">📋 Copy</button> | |
| </div> | |
| <div id="aRender" class="rendered"></div> | |
| `; | |
| /* Render Markdown+LaTeX into the respective blocks */ | |
| const qRender = document.getElementById('qRender'); | |
| const aRender = document.getElementById('aRender'); | |
| qRender.innerHTML = renderMdLatex(currentQuestion); /* Render question */ | |
| aRender.innerHTML = renderMdLatex(currentAnswer); /* Render answer */ | |
| /* Apply MathJax typesetting to the rendered blocks */ | |
| if (window.MathJax?.typesetPromise) { | |
| MathJax.typesetPromise([qRender, aRender]).then(hideProcessing) /* Hide processing indicator on success */ | |
| .catch(e => { console.error('MathJax error:', e); hideProcessing(); }); /* Hide on error */ | |
| } else { | |
| hideProcessing(); /* Hide if MathJax is not available */ | |
| } | |
| } | |
| /* ======= OCR with Nebius API ======= */ | |
| async function ocrImage(base64Image) { | |
| const nebiusKey = localStorage.getItem('nebius-api-key'); | |
| if (!nebiusKey) { | |
| alert('Please set your Nebius API key in settings (⚙️)'); | |
| return null; | |
| } | |
| showProcessing('Extracting text from image...'); | |
| try { | |
| const response = await fetch('https://api.studio.nebius.com/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Accept': '*/*', | |
| 'Authorization': `Bearer ${nebiusKey}` | |
| }, | |
| body: JSON.stringify({ | |
| model: 'google/gemma-3-27b-it', /* Model for OCR */ | |
| messages: [ | |
| { | |
| role: 'system', | |
| /* Enhanced prompt to prevent list environments and ensure raw LaTeX output */ | |
| content: 'OUTPUT ONLY the question text as plain text with LaTeX like $...$ or $$...$$. Do NOT SOLVE. NEVER use LaTeX list environments: \\itemize, \\enumerate, \\description, \\items or \\item. No bullet or numbered lists in LaTeX. If you need a list, write plain lines prefixed with "1) ", "a) " etc as text.' | |
| }, | |
| { | |
| role: 'user', | |
| content: [ | |
| { type: 'text', text: 'Image:' }, | |
| { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Image}` } } /* Image data */ | |
| ] | |
| } | |
| ] | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`OCR API error: ${response.status} - ${errorText}`); | |
| } | |
| const data = await response.json(); | |
| return data.choices[0].message.content; /* Return extracted text */ | |
| } catch (error) { | |
| console.error('OCR Error:', error); | |
| alert('Error during OCR: ' + error.message); | |
| return null; | |
| } | |
| } | |
| /* ======= Solve with Cerebras API (Streaming Optimization) ======= */ | |
| async function solveQuestion(question) { | |
| const cerebrasKey = localStorage.getItem('cerebras-api-key'); | |
| if (!cerebrasKey) { | |
| alert('Please set your Cerebras API key in settings (⚙️)'); | |
| return null; | |
| } | |
| showProcessing('Solving the question...'); | |
| const ui = beginStreamingUI(question); /* Prepare the UI for streaming */ | |
| try { | |
| const response = await fetch('https://api.cerebras.ai/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'text/event-stream', /* Request server-sent events */ | |
| 'Authorization': `Bearer ${cerebrasKey}` | |
| }, | |
| body: JSON.stringify({ | |
| model: 'gpt-oss-120b', | |
| stream: true, | |
| max_tokens: 65536, | |
| temperature: 0.1, /* Lower temperature for more deterministic answers */ | |
| reasoning_effort: 'medium', /* Medium reasoning effort */ | |
| messages: [ | |
| { role: 'system', content: 'Solve this Question. Provide a clear, step-by-step solution.' }, | |
| { role: 'user', content: question } /* User's question */ | |
| ] | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Cerebras API error: ${response.status} - ${errorText}`); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let fullAnswer = ''; | |
| let buffer = ''; /* Buffer for partial Server-Sent Events (SSE) frames */ | |
| let lastFlushTime = 0; | |
| const flushThrottle = 120; /* Milliseconds to wait between DOM updates to prevent jank */ | |
| /* Helper to update the UI during streaming */ | |
| const flushUI = () => { | |
| ui.aEl.textContent = fullAnswer; /* Update the answer display */ | |
| currentAnswer = fullAnswer; /* Update global variable for copy button */ | |
| lastFlushTime = performance.now(); /* Record time of last update */ | |
| }; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; /* Exit loop if stream is done */ | |
| buffer += decoder.decode(value, { stream: true }); /* Append decoded data */ | |
| const events = buffer.split('\n\n'); /* Split buffer by SSE frame delimiter */ | |
| buffer = events.pop() || ''; /* Keep any incomplete event for the next chunk */ | |
| for (const evt of events) { | |
| const dataLine = evt.split('\n').find(line => line.trim().startsWith('data: ')); /* Find data line */ | |
| if (!dataLine) continue; | |
| const data = dataLine.slice(6).trim(); /* Extract JSON data */ | |
| if (data === '[DONE]') continue; /* Ignore end-of-stream marker */ | |
| try { | |
| const parsed = JSON.parse(data); | |
| /* Extract content delta from potential response structures */ | |
| const deltaContent = parsed.choices?.[0]?.delta?.content | |
| ?? parsed.choices?.[0]?.message?.content | |
| ?? parsed.choices?.[0]?.text /* Fallback for other potential fields */ | |
| ?? ''; | |
| if (deltaContent) { | |
| fullAnswer += deltaContent; /* Append new content */ | |
| /* Throttle DOM updates to keep UI responsive */ | |
| if (performance.now() - lastFlushTime > flushThrottle) { | |
| flushUI(); | |
| } | |
| } | |
| } catch (e) { | |
| /* Log errors parsing chunks, but continue streaming */ | |
| console.error('Error parsing stream chunk data:', e, 'Chunk:', data); | |
| } | |
| } | |
| } | |
| flushUI(); /* Final flush to display any remaining content */ | |
| /* After streaming, perform the final heavy render with Markdown and MathJax */ | |
| finalizeStreaming(question, fullAnswer); | |
| return fullAnswer; /* Return the complete answer */ | |
| } catch (error) { | |
| console.error('Solving Error:', error); | |
| alert('Error during solving: ' + error.message); | |
| hideProcessing(); /* Ensure processing indicator is hidden on error */ | |
| return null; | |
| } | |
| } | |
| /* ======= Process image pipeline ======= */ | |
| async function processImage(file) { | |
| try { | |
| /* Convert image file to base64 string */ | |
| const base64 = await imageToBase64(file); | |
| /* OCR the image to get question text */ | |
| const ocrText = await ocrImage(base64); | |
| if (!ocrText) { | |
| /* Error handled within ocrImage, which calls hideProcessing */ | |
| return; | |
| } | |
| /* Solve the question using the extracted text */ | |
| const answer = await solveQuestion(ocrText); | |
| /* solveQuestion handles hiding the processing indicator */ | |
| } catch (error) { | |
| console.error('Image processing error:', error); | |
| alert('Error processing image: ' + error.message); | |
| hideProcessing(); /* Ensure processing indicator is hidden on error */ | |
| } | |
| } | |
| /* ======= Image to Base64 converter ======= */ | |
| async function imageToBase64(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| /* Ensure it's a valid data URL and extract base64 part */ | |
| if (reader.result && reader.result.includes(',')) { | |
| const base64 = reader.result.split(',')[1]; | |
| resolve(base64); | |
| } else { | |
| reject(new Error("Failed to read file as Data URL.")); | |
| } | |
| }; | |
| reader.onerror = () => reject(reader.error); /* Reject on reader error */ | |
| reader.readAsDataURL(file); /* Read file as Data URL */ | |
| }); | |
| } | |
| /* ======= FIXED paste listener - allows normal paste in input fields ======= */ | |
| document.addEventListener('paste', async (e) => { | |
| /* Check if the paste event is happening inside an input, textarea, or contenteditable element */ | |
| const activeElement = document.activeElement; | |
| const isInputField = activeElement && ( | |
| activeElement.tagName === 'INPUT' || | |
| activeElement.tagName === 'TEXTAREA' || | |
| activeElement.isContentEditable === true /* Modern check for editable content */ | |
| ); | |
| /* If pasting into an input field, let the browser handle it normally */ | |
| if (isInputField) { | |
| return; /* Do not intercept, allow default paste behavior */ | |
| } | |
| /* Otherwise, handle custom paste logic for the content area */ | |
| e.preventDefault(); /* Prevent default paste behavior */ | |
| /* Check for image files first in the clipboard data */ | |
| const items = Array.from(e.clipboardData.items); | |
| const imageItem = items.find(item => item.type.startsWith('image/')); | |
| if (imageItem) { | |
| /* Handle image paste: convert to base64, OCR, and solve */ | |
| const file = imageItem.getAsFile(); | |
| if (file) { | |
| await processImage(file); | |
| } else { | |
| alert("Could not get image file from clipboard."); | |
| } | |
| } else { | |
| /* Handle text paste: process it directly */ | |
| const txt = e.clipboardData.getData('text/plain'); | |
| if (txt.trim()) { | |
| processContent(txt); /* Use the processContent function for direct text pastes */ | |
| } | |
| } | |
| }); | |
| /* ======= Settings modal functions ======= */ | |
| const settingsBtn = document.getElementById('settingsBtn'); | |
| const settingsModal = document.getElementById('settingsModal'); | |
| const nebiusKeyInput = document.getElementById('nebiusKey'); | |
| const cerebrasKeyInput = document.getElementById('cerebrasKey'); | |
| settingsBtn.addEventListener('click', () => { | |
| /* Load existing API keys from localStorage when modal is opened */ | |
| nebiusKeyInput.value = localStorage.getItem('nebius-api-key') || ''; | |
| cerebrasKeyInput.value = localStorage.getItem('cerebras-api-key') || ''; | |
| settingsModal.classList.add('show'); /* Show the modal */ | |
| }); | |
| function closeSettings() { | |
| settingsModal.classList.remove('show'); /* Hide the modal */ | |
| } | |
| function saveSettings() { | |
| const nebiusKey = nebiusKeyInput.value.trim(); | |
| const cerebrasKey = cerebrasKeyInput.value.trim(); | |
| /* Save keys to localStorage if they are provided */ | |
| if (nebiusKey) localStorage.setItem('nebius-api-key', nebiusKey); | |
| if (cerebrasKey) localStorage.setItem('cerebras-api-key', cerebrasKey); | |
| closeSettings(); /* Close the modal */ | |
| alert('API keys saved successfully!'); /* Confirmation */ | |
| } | |
| /* Close modal on escape key press or background click */ | |
| settingsModal.addEventListener('click', (e) => { | |
| if (e.target === settingsModal) closeSettings(); /* Close if background clicked */ | |
| }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && settingsModal.classList.contains('show')) { | |
| closeSettings(); /* Close if Escape key pressed and modal is shown */ | |
| } | |
| }); | |
| /* small "bounce" on placeholder click */ | |
| content.addEventListener('click',()=>{ | |
| const ph=content.querySelector('.placeholder'); | |
| if(ph){ | |
| ph.style.transform='scale(.97)'; | |
| ph.style.transition='transform .12s'; | |
| setTimeout(()=>ph.style.transform='scale(1)',120); | |
| } | |
| }); | |
| /* smooth fade in animation for the container */ | |
| document.addEventListener('DOMContentLoaded',()=>{ | |
| const sheet=document.querySelector('.container'); | |
| sheet.style.opacity='0'; | |
| setTimeout(()=>{sheet.style.transition='opacity .6s ease';sheet.style.opacity='1'},80); | |
| }); | |
| /* ======= theme toggler ======= */ | |
| const btn = document.getElementById('themeToggle'); | |
| const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); | |
| const savedTheme = localStorage.getItem('note-theme'); | |
| initTheme(); /* Initialize theme on load */ | |
| btn.addEventListener('click',()=>{ | |
| document.body.classList.toggle('dark'); /* Toggle dark class on body */ | |
| updateIcon(); /* Update theme toggle icon */ | |
| localStorage.setItem('note-theme',document.body.classList.contains('dark')?'dark':'light'); /* Save theme preference */ | |
| }); | |
| function initTheme(){ | |
| /* Set initial theme based on saved preference or system preference */ | |
| if(savedTheme){ | |
| document.body.classList.toggle('dark',savedTheme==='dark'); | |
| }else if(prefersDark.matches){ | |
| document.body.classList.add('dark'); | |
| } | |
| updateIcon(); /* Set initial icon */ | |
| } | |
| function updateIcon(){ | |
| /* Update the moon/sun icon based on current theme */ | |
| btn.textContent=document.body.classList.contains('dark')?'☀️':'🌙'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |