Spaces:
Running
Running
mobile pov-ixes;cdns;etc..
Browse files- static/app.js +41 -28
- static/index.html +12 -4
- static/manifest.json +1 -1
- static/style.css +10 -0
static/app.js
CHANGED
|
@@ -724,7 +724,9 @@ if (imagePreviewRemove) imagePreviewRemove.addEventListener('click', () => {
|
|
| 724 |
============================================================ */
|
| 725 |
|
| 726 |
let currentAbortController = null;
|
| 727 |
-
let currentStreamReader = null;
|
|
|
|
|
|
|
| 728 |
|
| 729 |
function _showStopBtn() {
|
| 730 |
if (sendBtn) sendBtn.style.display = 'none';
|
|
@@ -737,23 +739,27 @@ function _showSendBtn() {
|
|
| 737 |
|
| 738 |
if (stopBtn) {
|
| 739 |
stopBtn.addEventListener('click', () => {
|
| 740 |
-
// 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
if (currentStreamReader) {
|
| 742 |
try { currentStreamReader.cancel(); } catch(_) {}
|
| 743 |
currentStreamReader = null;
|
| 744 |
}
|
| 745 |
-
//
|
| 746 |
if (currentAbortController) {
|
| 747 |
currentAbortController.abort();
|
| 748 |
currentAbortController = null;
|
| 749 |
}
|
| 750 |
-
//
|
| 751 |
isSending = false;
|
| 752 |
_showSendBtn();
|
| 753 |
const currentThinking = document.getElementById('currentThinking');
|
| 754 |
if (currentThinking) currentThinking.style.display = 'none';
|
| 755 |
document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
|
| 756 |
-
//
|
| 757 |
document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
|
| 758 |
});
|
| 759 |
}
|
|
@@ -844,15 +850,15 @@ function streamResponse(text) {
|
|
| 844 |
const contentEl = rowDiv.querySelector('.message-content');
|
| 845 |
let rawText = '';
|
| 846 |
let firstToken = true;
|
| 847 |
-
|
| 848 |
|
| 849 |
-
|
| 850 |
const RENDER_INTERVAL = 120;
|
| 851 |
function scheduleRender() {
|
| 852 |
-
if (
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
if (!
|
| 856 |
}, RENDER_INTERVAL);
|
| 857 |
}
|
| 858 |
|
|
@@ -879,8 +885,8 @@ function streamResponse(text) {
|
|
| 879 |
|
| 880 |
function read() {
|
| 881 |
reader.read().then(({ done, value }) => {
|
| 882 |
-
if (done ||
|
| 883 |
-
if (!
|
| 884 |
return;
|
| 885 |
}
|
| 886 |
|
|
@@ -888,24 +894,25 @@ function streamResponse(text) {
|
|
| 888 |
const lines = buffer.split('\n');
|
| 889 |
buffer = lines.pop();
|
| 890 |
|
| 891 |
-
|
| 892 |
-
if (
|
| 893 |
-
if (!line.startsWith('data: '))
|
| 894 |
const pl = line.substring(6);
|
| 895 |
if (pl === '[DONE]') {
|
| 896 |
-
finishStream(thinkingEl, contentEl, rawText,
|
| 897 |
-
|
| 898 |
}
|
| 899 |
try {
|
| 900 |
const data = JSON.parse(pl);
|
| 901 |
-
if (data.status === 'thinking') { thinkingText.textContent = data.message;
|
| 902 |
if (data.error) {
|
| 903 |
thinkingEl.style.display = 'none';
|
| 904 |
contentEl.style.display = 'block';
|
| 905 |
contentEl.innerHTML = `<div class="error-message">${escapeHtml(data.error)}</div>`;
|
| 906 |
-
isSending = false; _showSendBtn();
|
| 907 |
}
|
| 908 |
if (data.token !== undefined) {
|
|
|
|
| 909 |
if (firstToken) {
|
| 910 |
thinkingEl.style.display = 'none';
|
| 911 |
contentEl.style.display = 'block';
|
|
@@ -916,13 +923,11 @@ function streamResponse(text) {
|
|
| 916 |
scheduleRender();
|
| 917 |
}
|
| 918 |
} catch (_) {}
|
| 919 |
-
}
|
| 920 |
|
| 921 |
-
if (!
|
| 922 |
}).catch(err => {
|
| 923 |
-
|
| 924 |
-
if (err.name === 'AbortError' || stopped) return;
|
| 925 |
-
// Genuine error
|
| 926 |
thinkingEl.style.display = 'none';
|
| 927 |
contentEl.style.display = 'block';
|
| 928 |
if (!rawText) contentEl.innerHTML = '<div class="error-message">Connection lost. Please try again.</div>';
|
|
@@ -931,7 +936,7 @@ function streamResponse(text) {
|
|
| 931 |
}
|
| 932 |
read();
|
| 933 |
}).catch(err => {
|
| 934 |
-
if (err.name === 'AbortError') {
|
| 935 |
// User clicked stop before any response — that's fine
|
| 936 |
thinkingEl.style.display = 'none';
|
| 937 |
contentEl.style.display = 'block';
|
|
@@ -950,8 +955,9 @@ function streamResponse(text) {
|
|
| 950 |
});
|
| 951 |
}
|
| 952 |
|
| 953 |
-
function finishStream(thinkingEl, contentEl, rawText,
|
| 954 |
-
if (
|
|
|
|
| 955 |
thinkingEl.style.display = 'none';
|
| 956 |
contentEl.style.display = 'block';
|
| 957 |
contentEl.classList.remove('cursor');
|
|
@@ -1064,6 +1070,13 @@ if (installDismiss) installDismiss.addEventListener('click', () => {
|
|
| 1064 |
// Ensure sidebar starts collapsed in DOM (matches CSS default)
|
| 1065 |
if (sidebar) sidebar.classList.add('collapsed');
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
window.addEventListener('load', () => {
|
| 1068 |
checkExistingSession();
|
| 1069 |
_bindFeedbackSubmit();
|
|
|
|
| 724 |
============================================================ */
|
| 725 |
|
| 726 |
let currentAbortController = null;
|
| 727 |
+
let currentStreamReader = null;
|
| 728 |
+
let currentStreamStopped = false; // module-level so stop button can kill it
|
| 729 |
+
let currentRenderTimer = null; // so stop button can clear pending renders
|
| 730 |
|
| 731 |
function _showStopBtn() {
|
| 732 |
if (sendBtn) sendBtn.style.display = 'none';
|
|
|
|
| 739 |
|
| 740 |
if (stopBtn) {
|
| 741 |
stopBtn.addEventListener('click', () => {
|
| 742 |
+
// 1) Set the flag FIRST — this immediately stops token processing
|
| 743 |
+
currentStreamStopped = true;
|
| 744 |
+
// 2) Clear any pending render timer
|
| 745 |
+
if (currentRenderTimer) { clearTimeout(currentRenderTimer); currentRenderTimer = null; }
|
| 746 |
+
// 3) Cancel the stream reader so no more data arrives
|
| 747 |
if (currentStreamReader) {
|
| 748 |
try { currentStreamReader.cancel(); } catch(_) {}
|
| 749 |
currentStreamReader = null;
|
| 750 |
}
|
| 751 |
+
// 4) Abort the fetch connection so server detects disconnect
|
| 752 |
if (currentAbortController) {
|
| 753 |
currentAbortController.abort();
|
| 754 |
currentAbortController = null;
|
| 755 |
}
|
| 756 |
+
// 5) Reset UI state
|
| 757 |
isSending = false;
|
| 758 |
_showSendBtn();
|
| 759 |
const currentThinking = document.getElementById('currentThinking');
|
| 760 |
if (currentThinking) currentThinking.style.display = 'none';
|
| 761 |
document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
|
| 762 |
+
// 6) Remove the typing cursor from partial response
|
| 763 |
document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
|
| 764 |
});
|
| 765 |
}
|
|
|
|
| 850 |
const contentEl = rowDiv.querySelector('.message-content');
|
| 851 |
let rawText = '';
|
| 852 |
let firstToken = true;
|
| 853 |
+
currentStreamStopped = false; // reset for this new stream
|
| 854 |
|
| 855 |
+
currentRenderTimer = null;
|
| 856 |
const RENDER_INTERVAL = 120;
|
| 857 |
function scheduleRender() {
|
| 858 |
+
if (currentRenderTimer || currentStreamStopped) return;
|
| 859 |
+
currentRenderTimer = setTimeout(() => {
|
| 860 |
+
currentRenderTimer = null;
|
| 861 |
+
if (!currentStreamStopped) { renderFinalContent(contentEl, rawText); scrollToBottom(); }
|
| 862 |
}, RENDER_INTERVAL);
|
| 863 |
}
|
| 864 |
|
|
|
|
| 885 |
|
| 886 |
function read() {
|
| 887 |
reader.read().then(({ done, value }) => {
|
| 888 |
+
if (done || currentStreamStopped) {
|
| 889 |
+
if (!currentStreamStopped) finishStream(thinkingEl, contentEl, rawText, currentRenderTimer);
|
| 890 |
return;
|
| 891 |
}
|
| 892 |
|
|
|
|
| 894 |
const lines = buffer.split('\n');
|
| 895 |
buffer = lines.pop();
|
| 896 |
|
| 897 |
+
for (const line of lines) {
|
| 898 |
+
if (currentStreamStopped) return; // bail immediately
|
| 899 |
+
if (!line.startsWith('data: ')) continue;
|
| 900 |
const pl = line.substring(6);
|
| 901 |
if (pl === '[DONE]') {
|
| 902 |
+
finishStream(thinkingEl, contentEl, rawText, currentRenderTimer);
|
| 903 |
+
currentStreamStopped = true; return;
|
| 904 |
}
|
| 905 |
try {
|
| 906 |
const data = JSON.parse(pl);
|
| 907 |
+
if (data.status === 'thinking') { thinkingText.textContent = data.message; continue; }
|
| 908 |
if (data.error) {
|
| 909 |
thinkingEl.style.display = 'none';
|
| 910 |
contentEl.style.display = 'block';
|
| 911 |
contentEl.innerHTML = `<div class="error-message">${escapeHtml(data.error)}</div>`;
|
| 912 |
+
isSending = false; _showSendBtn(); currentStreamStopped = true; return;
|
| 913 |
}
|
| 914 |
if (data.token !== undefined) {
|
| 915 |
+
if (currentStreamStopped) return; // double-check before adding token
|
| 916 |
if (firstToken) {
|
| 917 |
thinkingEl.style.display = 'none';
|
| 918 |
contentEl.style.display = 'block';
|
|
|
|
| 923 |
scheduleRender();
|
| 924 |
}
|
| 925 |
} catch (_) {}
|
| 926 |
+
}
|
| 927 |
|
| 928 |
+
if (!currentStreamStopped) read();
|
| 929 |
}).catch(err => {
|
| 930 |
+
if (err.name === 'AbortError' || currentStreamStopped) return;
|
|
|
|
|
|
|
| 931 |
thinkingEl.style.display = 'none';
|
| 932 |
contentEl.style.display = 'block';
|
| 933 |
if (!rawText) contentEl.innerHTML = '<div class="error-message">Connection lost. Please try again.</div>';
|
|
|
|
| 936 |
}
|
| 937 |
read();
|
| 938 |
}).catch(err => {
|
| 939 |
+
if (err.name === 'AbortError' || currentStreamStopped) {
|
| 940 |
// User clicked stop before any response — that's fine
|
| 941 |
thinkingEl.style.display = 'none';
|
| 942 |
contentEl.style.display = 'block';
|
|
|
|
| 955 |
});
|
| 956 |
}
|
| 957 |
|
| 958 |
+
function finishStream(thinkingEl, contentEl, rawText, timer) {
|
| 959 |
+
if (timer) clearTimeout(timer);
|
| 960 |
+
currentRenderTimer = null;
|
| 961 |
thinkingEl.style.display = 'none';
|
| 962 |
contentEl.style.display = 'block';
|
| 963 |
contentEl.classList.remove('cursor');
|
|
|
|
| 1070 |
// Ensure sidebar starts collapsed in DOM (matches CSS default)
|
| 1071 |
if (sidebar) sidebar.classList.add('collapsed');
|
| 1072 |
|
| 1073 |
+
// Lock screen to portrait on mobile
|
| 1074 |
+
try {
|
| 1075 |
+
if (screen.orientation && screen.orientation.lock) {
|
| 1076 |
+
screen.orientation.lock('portrait').catch(() => {});
|
| 1077 |
+
}
|
| 1078 |
+
} catch(_) {}
|
| 1079 |
+
|
| 1080 |
window.addEventListener('load', () => {
|
| 1081 |
checkExistingSession();
|
| 1082 |
_bindFeedbackSubmit();
|
static/index.html
CHANGED
|
@@ -2,18 +2,26 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
<title>STEM Copilot</title>
|
| 7 |
<meta name="description" content="AI-powered NCERT tutor for Class XI and XII Physics, Chemistry, and Mathematics.">
|
| 8 |
<meta name="theme-color" content="#0a0a0a">
|
| 9 |
<link rel="icon" type="image/png" href="/assets/bot.png">
|
| 10 |
<link rel="manifest" href="/manifest.json">
|
| 11 |
<link rel="apple-touch-icon" href="/assets/stem_black.png">
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
| 14 |
<link rel="stylesheet" href="/static/style.css">
|
| 15 |
-
<!-- Google Identity Services: loaded dynamically by initGoogleAuth() in app.js
|
| 16 |
-
to guarantee initialize() is called only after the script is ready. -->
|
| 17 |
</head>
|
| 18 |
<body>
|
| 19 |
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
| 6 |
+
<meta name="screen-orientation" content="portrait">
|
| 7 |
+
<meta name="x5-orientation" content="portrait">
|
| 8 |
+
<meta name="mobile-web-app-capable" content="yes">
|
| 9 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 10 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 11 |
<title>STEM Copilot</title>
|
| 12 |
<meta name="description" content="AI-powered NCERT tutor for Class XI and XII Physics, Chemistry, and Mathematics.">
|
| 13 |
<meta name="theme-color" content="#0a0a0a">
|
| 14 |
<link rel="icon" type="image/png" href="/assets/bot.png">
|
| 15 |
<link rel="manifest" href="/manifest.json">
|
| 16 |
<link rel="apple-touch-icon" href="/assets/stem_black.png">
|
| 17 |
+
<!-- Preconnect for faster font/CDN loading -->
|
| 18 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 19 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 20 |
+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
| 21 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 22 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
| 23 |
<link rel="stylesheet" href="/static/style.css">
|
| 24 |
+
<!-- Google Identity Services: loaded dynamically by initGoogleAuth() in app.js -->
|
|
|
|
| 25 |
</head>
|
| 26 |
<body>
|
| 27 |
|
static/manifest.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
"display": "standalone",
|
| 8 |
"background_color": "#000000",
|
| 9 |
"theme_color": "#000000",
|
| 10 |
-
"orientation": "
|
| 11 |
"prefer_related_applications": false,
|
| 12 |
"icons": [
|
| 13 |
{
|
|
|
|
| 7 |
"display": "standalone",
|
| 8 |
"background_color": "#000000",
|
| 9 |
"theme_color": "#000000",
|
| 10 |
+
"orientation": "portrait",
|
| 11 |
"prefer_related_applications": false,
|
| 12 |
"icons": [
|
| 13 |
{
|
static/style.css
CHANGED
|
@@ -30,6 +30,13 @@
|
|
| 30 |
|
| 31 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
body {
|
| 34 |
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 35 |
background-color: var(--bg-main);
|
|
@@ -37,6 +44,9 @@ body {
|
|
| 37 |
height: 100vh;
|
| 38 |
height: 100dvh;
|
| 39 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
|
|
|
|
| 30 |
|
| 31 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 32 |
|
| 33 |
+
html {
|
| 34 |
+
-webkit-text-size-adjust: 100%;
|
| 35 |
+
-ms-text-size-adjust: 100%;
|
| 36 |
+
touch-action: manipulation;
|
| 37 |
+
overscroll-behavior: none;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
body {
|
| 41 |
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 42 |
background-color: var(--bg-main);
|
|
|
|
| 44 |
height: 100vh;
|
| 45 |
height: 100dvh;
|
| 46 |
overflow: hidden;
|
| 47 |
+
touch-action: manipulation;
|
| 48 |
+
-webkit-tap-highlight-color: transparent;
|
| 49 |
+
overscroll-behavior: none;
|
| 50 |
}
|
| 51 |
|
| 52 |
|