Spaces:
Running
Running
fixes & improvements..
Browse files- app.py +10 -2
- graph.py +22 -11
- static/app.js +191 -315
- static/index.html +1 -1
app.py
CHANGED
|
@@ -18,7 +18,7 @@ import guard
|
|
| 18 |
import retriever
|
| 19 |
import auth as auth_module
|
| 20 |
from config import EXPORT_SECRET, REDIS_URI, GOOGLE_CLIENT_ID
|
| 21 |
-
from graph import chatbot,
|
| 22 |
|
| 23 |
|
| 24 |
app = FastAPI()
|
|
@@ -276,6 +276,8 @@ def chat(request: ChatRequest, req: Request):
|
|
| 276 |
|
| 277 |
# Step 5: Stream LLM response via LangGraph
|
| 278 |
yield sse_status("Preparing the explanation...")
|
|
|
|
|
|
|
| 279 |
config = {
|
| 280 |
"configurable": {
|
| 281 |
"thread_id": request.thread_id,
|
|
@@ -285,6 +287,7 @@ def chat(request: ChatRequest, req: Request):
|
|
| 285 |
"username": request.username,
|
| 286 |
"student_profile": student_profile,
|
| 287 |
"user_api_key": user_api_key,
|
|
|
|
| 288 |
}
|
| 289 |
}
|
| 290 |
|
|
@@ -305,6 +308,11 @@ def chat(request: ChatRequest, req: Request):
|
|
| 305 |
"I'm getting a lot of questions right now. "
|
| 306 |
"Please try again in a few seconds."
|
| 307 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
elif "401" in str(e) or "unauthorized" in error_str or "invalid" in error_str:
|
| 309 |
yield sse_error(
|
| 310 |
"API key issue. Please check your OpenRouter API key in Settings."
|
|
@@ -337,7 +345,7 @@ def chat(request: ChatRequest, req: Request):
|
|
| 337 |
persona=request.persona, language=request.language,
|
| 338 |
username=request.username, user_agent=user_agent,
|
| 339 |
sources=sources, user_id=request.user_id,
|
| 340 |
-
model_used=
|
| 341 |
)
|
| 342 |
|
| 343 |
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
|
|
| 18 |
import retriever
|
| 19 |
import auth as auth_module
|
| 20 |
from config import EXPORT_SECRET, REDIS_URI, GOOGLE_CLIENT_ID
|
| 21 |
+
from graph import chatbot, TEXT_MODEL, VISION_MODEL
|
| 22 |
|
| 23 |
|
| 24 |
app = FastAPI()
|
|
|
|
| 276 |
|
| 277 |
# Step 5: Stream LLM response via LangGraph
|
| 278 |
yield sse_status("Preparing the explanation...")
|
| 279 |
+
has_image = bool(request.image)
|
| 280 |
+
model_used = VISION_MODEL if has_image else TEXT_MODEL
|
| 281 |
config = {
|
| 282 |
"configurable": {
|
| 283 |
"thread_id": request.thread_id,
|
|
|
|
| 287 |
"username": request.username,
|
| 288 |
"student_profile": student_profile,
|
| 289 |
"user_api_key": user_api_key,
|
| 290 |
+
"has_image": has_image,
|
| 291 |
}
|
| 292 |
}
|
| 293 |
|
|
|
|
| 308 |
"I'm getting a lot of questions right now. "
|
| 309 |
"Please try again in a few seconds."
|
| 310 |
)
|
| 311 |
+
elif "402" in str(e) or "payment" in error_str or "credits" in error_str:
|
| 312 |
+
yield sse_error(
|
| 313 |
+
"Free credits exhausted. Please add credits at openrouter.ai/settings/credits "
|
| 314 |
+
"or update your API key in Settings."
|
| 315 |
+
)
|
| 316 |
elif "401" in str(e) or "unauthorized" in error_str or "invalid" in error_str:
|
| 317 |
yield sse_error(
|
| 318 |
"API key issue. Please check your OpenRouter API key in Settings."
|
|
|
|
| 345 |
persona=request.persona, language=request.language,
|
| 346 |
username=request.username, user_agent=user_agent,
|
| 347 |
sources=sources, user_id=request.user_id,
|
| 348 |
+
model_used=model_used,
|
| 349 |
)
|
| 350 |
|
| 351 |
return StreamingResponse(stream(), media_type="text/event-stream")
|
graph.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
-
LangGraph definition — single chat node with
|
| 3 |
-
Checkpointer: SQLite via official LangGraph SqliteSaver.
|
| 4 |
|
| 5 |
-
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from langgraph.graph import StateGraph, START, END
|
|
@@ -23,22 +24,31 @@ _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
| 23 |
checkpointer = SqliteSaver(conn=_conn)
|
| 24 |
|
| 25 |
|
| 26 |
-
# ---
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
-
def _get_llm(api_key: str = "", model: str = ""):
|
| 31 |
-
"""Create an LLM instance
|
| 32 |
key = api_key or OPENROUTER_API_KEY
|
| 33 |
if not key:
|
| 34 |
raise ValueError("No API key available. Please add your OpenRouter key in Settings.")
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return ChatOpenRouter(
|
| 37 |
model=mdl,
|
| 38 |
openrouter_api_key=key,
|
| 39 |
temperature=0.5,
|
| 40 |
-
max_tokens=
|
| 41 |
-
max_retries=
|
| 42 |
streaming=True,
|
| 43 |
)
|
| 44 |
|
|
@@ -60,12 +70,13 @@ def chat_node(state: ChatState, config: RunnableConfig):
|
|
| 60 |
student_profile = cfg.get("student_profile", "")
|
| 61 |
user_api_key = cfg.get("user_api_key", "")
|
| 62 |
model_name = cfg.get("model", "")
|
|
|
|
| 63 |
|
| 64 |
system_msg = SystemMessage(
|
| 65 |
content=prompts.build(persona, context, language, username, student_profile)
|
| 66 |
)
|
| 67 |
|
| 68 |
-
llm = _get_llm(api_key=user_api_key, model=model_name)
|
| 69 |
all_msgs = [system_msg] + state["messages"]
|
| 70 |
response = llm.invoke(all_msgs)
|
| 71 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
LangGraph definition — single chat node with free OpenRouter model selection.
|
| 3 |
+
Checkpointer: SQLite via official LangGraph SqliteSaver.
|
| 4 |
|
| 5 |
+
Text model: nvidia/llama-3.1-nemotron-ultra-253b-v1:free
|
| 6 |
+
Vision model: nex-agi/nex-n2-pro:free
|
| 7 |
"""
|
| 8 |
|
| 9 |
from langgraph.graph import StateGraph, START, END
|
|
|
|
| 24 |
checkpointer = SqliteSaver(conn=_conn)
|
| 25 |
|
| 26 |
|
| 27 |
+
# --- Free model config ---
|
| 28 |
+
TEXT_MODEL = "nvidia/llama-3.1-nemotron-ultra-253b-v1:free"
|
| 29 |
+
VISION_MODEL = "nex-agi/nex-n2-pro:free"
|
| 30 |
|
| 31 |
|
| 32 |
+
def _get_llm(api_key: str = "", model: str = "", has_image: bool = False):
|
| 33 |
+
"""Create an LLM instance. Picks vision model if image is attached."""
|
| 34 |
key = api_key or OPENROUTER_API_KEY
|
| 35 |
if not key:
|
| 36 |
raise ValueError("No API key available. Please add your OpenRouter key in Settings.")
|
| 37 |
+
|
| 38 |
+
# Model priority: explicit override > auto-select based on image
|
| 39 |
+
if model:
|
| 40 |
+
mdl = model
|
| 41 |
+
elif has_image:
|
| 42 |
+
mdl = VISION_MODEL
|
| 43 |
+
else:
|
| 44 |
+
mdl = TEXT_MODEL
|
| 45 |
+
|
| 46 |
return ChatOpenRouter(
|
| 47 |
model=mdl,
|
| 48 |
openrouter_api_key=key,
|
| 49 |
temperature=0.5,
|
| 50 |
+
max_tokens=4096,
|
| 51 |
+
max_retries=3,
|
| 52 |
streaming=True,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 70 |
student_profile = cfg.get("student_profile", "")
|
| 71 |
user_api_key = cfg.get("user_api_key", "")
|
| 72 |
model_name = cfg.get("model", "")
|
| 73 |
+
has_image = cfg.get("has_image", False)
|
| 74 |
|
| 75 |
system_msg = SystemMessage(
|
| 76 |
content=prompts.build(persona, context, language, username, student_profile)
|
| 77 |
)
|
| 78 |
|
| 79 |
+
llm = _get_llm(api_key=user_api_key, model=model_name, has_image=has_image)
|
| 80 |
all_msgs = [system_msg] + state["messages"]
|
| 81 |
response = llm.invoke(all_msgs)
|
| 82 |
|
static/app.js
CHANGED
|
@@ -32,44 +32,79 @@ const chatContainer = document.getElementById('chatContainer');
|
|
| 32 |
const welcomeScreen = document.getElementById('welcomeScreen');
|
| 33 |
const bottomInputContainer = document.getElementById('bottomInputContainer');
|
| 34 |
|
| 35 |
-
// Bottom input bar
|
| 36 |
const userInput = document.getElementById('userInput');
|
| 37 |
const sendBtn = document.getElementById('sendBtn');
|
| 38 |
const newChatBtn = document.getElementById('newChatBtn');
|
| 39 |
const stopBtn = document.getElementById('stopBtn');
|
| 40 |
|
| 41 |
-
// Hero input bar
|
| 42 |
const heroInput = document.getElementById('heroInput');
|
| 43 |
const heroSendBtn = document.getElementById('heroSendBtn');
|
| 44 |
const heroUploadBtn = document.getElementById('heroUploadBtn');
|
| 45 |
const heroImageInput = document.getElementById('heroImageInput');
|
| 46 |
|
| 47 |
-
// Auth
|
| 48 |
const byokInput = document.getElementById('byokInput');
|
| 49 |
const byokSubmitBtn = document.getElementById('byokSubmitBtn');
|
| 50 |
|
| 51 |
-
// User menu
|
| 52 |
const userProfileBtn = document.getElementById('userProfileBtn');
|
| 53 |
const userMenu = document.getElementById('userMenu');
|
| 54 |
const userAvatar = document.getElementById('userAvatar');
|
| 55 |
const userDisplayName = document.getElementById('userDisplayName');
|
| 56 |
const logoutBtn = document.getElementById('logoutBtn');
|
| 57 |
|
| 58 |
-
// Rail
|
| 59 |
const railExpandBtn = document.getElementById('railExpandBtn');
|
| 60 |
const railNewChatBtn = document.getElementById('railNewChatBtn');
|
| 61 |
const railProfileBtn = document.getElementById('railProfileBtn');
|
| 62 |
const railAvatar = document.getElementById('railAvatar');
|
| 63 |
|
| 64 |
-
// Settings
|
| 65 |
const settingsOverlay = document.getElementById('settingsOverlay');
|
| 66 |
const openSettingsBtn = document.getElementById('openSettingsBtn');
|
| 67 |
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
|
| 68 |
const settingsModal = document.getElementById('settingsModal');
|
| 69 |
-
|
| 70 |
const feedbackMenuBtn = document.getElementById('feedbackMenuBtn');
|
| 71 |
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
function openFeedbackInSettings() {
|
| 74 |
userMenu.classList.remove('show');
|
| 75 |
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
|
|
@@ -80,10 +115,7 @@ function openFeedbackInSettings() {
|
|
| 80 |
if (fbTab) fbTab.classList.add('active');
|
| 81 |
settingsOverlay.classList.add('show');
|
| 82 |
}
|
| 83 |
-
|
| 84 |
-
if (feedbackMenuBtn) {
|
| 85 |
-
feedbackMenuBtn.addEventListener('click', openFeedbackInSettings);
|
| 86 |
-
}
|
| 87 |
|
| 88 |
function _bindFeedbackSubmit() {
|
| 89 |
const submitFeedbackBtn = document.getElementById('submitFeedbackBtn');
|
|
@@ -92,51 +124,31 @@ function _bindFeedbackSubmit() {
|
|
| 92 |
const cat = document.getElementById('feedbackCategory').value;
|
| 93 |
const msg = document.getElementById('feedbackMessage').value;
|
| 94 |
if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; }
|
| 95 |
-
|
| 96 |
submitFeedbackBtn.disabled = true;
|
| 97 |
submitFeedbackBtn.textContent = 'Submitting...';
|
| 98 |
-
|
| 99 |
fetch('/feedback', {
|
| 100 |
method: 'POST',
|
| 101 |
headers: { 'Content-Type': 'application/json' },
|
| 102 |
body: JSON.stringify({
|
| 103 |
user_id: currentUser ? currentUser.google_id : 'anonymous',
|
| 104 |
-
category: cat,
|
| 105 |
-
message: msg
|
| 106 |
})
|
| 107 |
-
}).then(r =>
|
|
|
|
|
|
|
|
|
|
| 108 |
settingsOverlay.classList.remove('show');
|
| 109 |
document.getElementById('feedbackMessage').value = '';
|
| 110 |
-
|
| 111 |
-
submitFeedbackBtn.textContent = 'Submit Securely';
|
| 112 |
-
alert('Feedback submitted. Thank you!');
|
| 113 |
}).catch(() => {
|
|
|
|
|
|
|
| 114 |
submitFeedbackBtn.disabled = false;
|
| 115 |
submitFeedbackBtn.textContent = 'Submit Securely';
|
| 116 |
-
alert('Could not submit feedback. Please try again.');
|
| 117 |
});
|
| 118 |
});
|
| 119 |
}
|
| 120 |
|
| 121 |
-
const usernameInput = document.getElementById('usernameInput');
|
| 122 |
-
const languageSelect = document.getElementById('languageSelect');
|
| 123 |
-
const profileInput = document.getElementById('profileInput');
|
| 124 |
-
const saveProfileBtn = document.getElementById('saveProfileBtn');
|
| 125 |
-
const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
|
| 126 |
-
const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
|
| 127 |
-
|
| 128 |
-
// Image upload (bottom bar)
|
| 129 |
-
const uploadBtn = document.getElementById('uploadBtn');
|
| 130 |
-
const imageInput = document.getElementById('imageInput');
|
| 131 |
-
const imagePreviewBar = document.getElementById('imagePreviewBar');
|
| 132 |
-
const imagePreviewThumb = document.getElementById('imagePreviewThumb');
|
| 133 |
-
const imagePreviewRemove= document.getElementById('imagePreviewRemove');
|
| 134 |
-
|
| 135 |
-
// PWA
|
| 136 |
-
const installBanner = document.getElementById('installBanner');
|
| 137 |
-
const installBtn = document.getElementById('installBtn');
|
| 138 |
-
const installDismiss = document.getElementById('installDismiss');
|
| 139 |
-
|
| 140 |
|
| 141 |
/* ============================================================
|
| 142 |
AUTH — Google Sign-In
|
|
@@ -145,38 +157,26 @@ const installDismiss = document.getElementById('installDismiss');
|
|
| 145 |
let _gsiInitialized = false;
|
| 146 |
|
| 147 |
function initGoogleAuth() {
|
| 148 |
-
return new Promise((resolve
|
| 149 |
if (_gsiInitialized) { resolve(); return; }
|
| 150 |
-
|
| 151 |
fetch('/auth/client_id')
|
| 152 |
.then(r => r.json())
|
| 153 |
.then(data => {
|
| 154 |
-
if (!data.client_id) {
|
| 155 |
-
showApp();
|
| 156 |
-
resolve();
|
| 157 |
-
return;
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
const script = document.createElement('script');
|
| 161 |
script.src = 'https://accounts.google.com/gsi/client';
|
| 162 |
-
script.async = true;
|
| 163 |
-
script.defer = true;
|
| 164 |
script.onload = () => {
|
| 165 |
google.accounts.id.initialize({
|
| 166 |
client_id: data.client_id,
|
| 167 |
callback: handleGoogleCredential,
|
| 168 |
-
auto_select: false,
|
| 169 |
-
cancel_on_tap_outside: false,
|
| 170 |
});
|
| 171 |
_renderHiddenGoogleBtn();
|
| 172 |
_gsiInitialized = true;
|
| 173 |
resolve();
|
| 174 |
};
|
| 175 |
-
script.onerror = () => {
|
| 176 |
-
console.error('[AUTH] Failed to load Google Identity Services');
|
| 177 |
-
showApp();
|
| 178 |
-
resolve();
|
| 179 |
-
};
|
| 180 |
document.head.appendChild(script);
|
| 181 |
})
|
| 182 |
.catch(() => { showApp(); resolve(); });
|
|
@@ -188,37 +188,23 @@ function _renderHiddenGoogleBtn(cb) {
|
|
| 188 |
if (!container) return;
|
| 189 |
container.innerHTML = '';
|
| 190 |
google.accounts.id.renderButton(container, {
|
| 191 |
-
type: 'standard',
|
| 192 |
-
|
| 193 |
-
size: 'large',
|
| 194 |
-
text: 'signin_with',
|
| 195 |
-
shape: 'pill',
|
| 196 |
-
width: 240,
|
| 197 |
});
|
| 198 |
-
setTimeout(() => {
|
| 199 |
-
container.style.pointerEvents = 'auto';
|
| 200 |
-
if (cb) cb();
|
| 201 |
-
}, 150);
|
| 202 |
}
|
| 203 |
|
| 204 |
function triggerGoogleSignIn() {
|
| 205 |
const tryClick = () => {
|
| 206 |
const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
|
| 207 |
-
if (realBtn) {
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
if (btn) btn.click();
|
| 213 |
-
});
|
| 214 |
-
}
|
| 215 |
};
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
tryClick();
|
| 219 |
-
} else {
|
| 220 |
-
initGoogleAuth().then(tryClick);
|
| 221 |
-
}
|
| 222 |
}
|
| 223 |
|
| 224 |
function handleGoogleCredential(response) {
|
|
@@ -227,16 +213,19 @@ function handleGoogleCredential(response) {
|
|
| 227 |
headers: { 'Content-Type': 'application/json' },
|
| 228 |
body: JSON.stringify({ token: response.credential }),
|
| 229 |
})
|
| 230 |
-
.then(r =>
|
|
|
|
|
|
|
|
|
|
| 231 |
.then(data => {
|
| 232 |
-
if (data.error) {
|
| 233 |
currentUser = data.user;
|
| 234 |
localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
|
| 235 |
currentUsername = currentUser.name;
|
| 236 |
localStorage.setItem('stemcopilot_username', currentUsername);
|
| 237 |
-
if (!data.has_api_key)
|
| 238 |
})
|
| 239 |
-
.catch(() =>
|
| 240 |
}
|
| 241 |
|
| 242 |
function checkExistingSession() {
|
|
@@ -254,7 +243,7 @@ function checkExistingSession() {
|
|
| 254 |
return;
|
| 255 |
}
|
| 256 |
currentUser = data.user;
|
| 257 |
-
if (!data.has_api_key)
|
| 258 |
})
|
| 259 |
.catch(() => initGoogleAuth());
|
| 260 |
} else {
|
|
@@ -281,9 +270,9 @@ if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => {
|
|
| 281 |
headers: { 'Content-Type': 'application/json' },
|
| 282 |
body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
|
| 283 |
})
|
| 284 |
-
.then(r => r.json())
|
| 285 |
.then(() => showApp())
|
| 286 |
-
.catch(() =>
|
| 287 |
});
|
| 288 |
|
| 289 |
|
|
@@ -315,12 +304,10 @@ function showApp() {
|
|
| 315 |
if (usernameInput) usernameInput.value = currentUsername;
|
| 316 |
if (languageSelect) languageSelect.value = currentLanguage;
|
| 317 |
|
| 318 |
-
// Set active persona
|
| 319 |
document.querySelectorAll('.persona-option').forEach(opt => {
|
| 320 |
opt.classList.toggle('active', opt.dataset.persona === currentPersona);
|
| 321 |
});
|
| 322 |
|
| 323 |
-
// Load thread history
|
| 324 |
const userId = currentUser ? currentUser.google_id : '';
|
| 325 |
fetch('/threads?user_id=' + encodeURIComponent(userId))
|
| 326 |
.then(r => r.json())
|
|
@@ -330,10 +317,8 @@ function showApp() {
|
|
| 330 |
})
|
| 331 |
.catch(() => {});
|
| 332 |
|
| 333 |
-
//
|
| 334 |
-
if (window.innerWidth <= 768)
|
| 335 |
-
collapseSidebar();
|
| 336 |
-
}
|
| 337 |
|
| 338 |
enterHeroMode();
|
| 339 |
}
|
|
@@ -363,13 +348,12 @@ function exitHeroMode() {
|
|
| 363 |
|
| 364 |
|
| 365 |
/* ============================================================
|
| 366 |
-
SIDEBAR
|
| 367 |
============================================================ */
|
| 368 |
|
| 369 |
let sidebarOpen = false;
|
| 370 |
|
| 371 |
function _cleanSidebarStyles() {
|
| 372 |
-
// Remove ANY leftover inline styles from old drag system
|
| 373 |
sidebar.style.transition = '';
|
| 374 |
sidebar.style.transform = '';
|
| 375 |
sidebar.style.opacity = '';
|
|
@@ -385,9 +369,7 @@ function openSidebar() {
|
|
| 385 |
sidebarOpen = true;
|
| 386 |
_cleanSidebarStyles();
|
| 387 |
sidebar.classList.remove('collapsed');
|
| 388 |
-
if (sidebarOverlay && window.innerWidth <= 768)
|
| 389 |
-
sidebarOverlay.classList.add('visible');
|
| 390 |
-
}
|
| 391 |
if (sidebarRail) sidebarRail.classList.remove('visible');
|
| 392 |
}
|
| 393 |
|
|
@@ -396,34 +378,24 @@ function closeSidebar() {
|
|
| 396 |
_cleanSidebarStyles();
|
| 397 |
sidebar.classList.add('collapsed');
|
| 398 |
if (sidebarOverlay) sidebarOverlay.classList.remove('visible');
|
| 399 |
-
|
| 400 |
-
if (sidebarRail && window.innerWidth > 768) {
|
| 401 |
-
sidebarRail.classList.add('visible');
|
| 402 |
-
}
|
| 403 |
}
|
| 404 |
|
| 405 |
function toggleSidebar() {
|
| 406 |
-
if (sidebarOpen) closeSidebar();
|
| 407 |
-
else openSidebar();
|
| 408 |
}
|
| 409 |
|
| 410 |
-
|
| 411 |
-
if (toggleSidebarBtn) {
|
| 412 |
-
toggleSidebarBtn.addEventListener('click', toggleSidebar);
|
| 413 |
-
}
|
| 414 |
|
| 415 |
-
// Overlay tap closes sidebar
|
| 416 |
if (sidebarOverlay) {
|
| 417 |
sidebarOverlay.addEventListener('click', closeSidebar);
|
| 418 |
sidebarOverlay.addEventListener('touchstart', (e) => {
|
| 419 |
-
e.preventDefault();
|
| 420 |
-
closeSidebar();
|
| 421 |
}, { passive: false });
|
| 422 |
}
|
| 423 |
|
| 424 |
-
//
|
| 425 |
-
let _touchStartX = 0;
|
| 426 |
-
let _touchStartY = 0;
|
| 427 |
document.addEventListener('touchstart', (e) => {
|
| 428 |
if (window.innerWidth > 768) return;
|
| 429 |
_touchStartX = e.changedTouches[0].clientX;
|
|
@@ -434,19 +406,13 @@ document.addEventListener('touchend', (e) => {
|
|
| 434 |
if (window.innerWidth > 768) return;
|
| 435 |
const dx = e.changedTouches[0].clientX - _touchStartX;
|
| 436 |
const dy = Math.abs(e.changedTouches[0].clientY - _touchStartY);
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
}
|
| 443 |
-
// Swipe left → close
|
| 444 |
-
if (sidebarOpen && dx < -60) {
|
| 445 |
-
closeSidebar();
|
| 446 |
-
}
|
| 447 |
}, { passive: true });
|
| 448 |
|
| 449 |
-
// Rail buttons
|
| 450 |
if (railExpandBtn) railExpandBtn.addEventListener('click', openSidebar);
|
| 451 |
if (railNewChatBtn) railNewChatBtn.addEventListener('click', () => startNewChat());
|
| 452 |
if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
|
|
@@ -454,13 +420,8 @@ if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
|
|
| 454 |
setTimeout(() => { if (userProfileBtn) userProfileBtn.click(); }, 150);
|
| 455 |
});
|
| 456 |
|
| 457 |
-
// Mobile FAB toggle
|
| 458 |
const mobileFabToggle = document.getElementById('mobileFabToggle');
|
| 459 |
-
if (mobileFabToggle)
|
| 460 |
-
mobileFabToggle.addEventListener('click', toggleSidebar);
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
// New chat button
|
| 464 |
if (newChatBtn) newChatBtn.addEventListener('click', startNewChat);
|
| 465 |
|
| 466 |
function startNewChat() {
|
|
@@ -469,7 +430,6 @@ function startNewChat() {
|
|
| 469 |
chatContainer.appendChild(createWelcomeScreen());
|
| 470 |
enterHeroMode();
|
| 471 |
renderHistory();
|
| 472 |
-
// Close sidebar on mobile after starting new chat
|
| 473 |
if (window.innerWidth <= 768) closeSidebar();
|
| 474 |
}
|
| 475 |
|
|
@@ -506,36 +466,19 @@ function createWelcomeScreen() {
|
|
| 506 |
const dynFileInput = div.querySelector('#heroImageInputDynamic');
|
| 507 |
|
| 508 |
dynInput.addEventListener('input', function() {
|
| 509 |
-
this.style.height = '54px';
|
| 510 |
-
this.style.height = this.scrollHeight + 'px';
|
| 511 |
});
|
| 512 |
-
|
| 513 |
dynInput.addEventListener('keydown', function(e) {
|
| 514 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 515 |
-
e.preventDefault();
|
| 516 |
-
userInput.value = dynInput.value;
|
| 517 |
-
sendMessage();
|
| 518 |
}
|
| 519 |
});
|
| 520 |
-
|
| 521 |
-
dynSend.addEventListener('click', () => {
|
| 522 |
-
userInput.value = dynInput.value;
|
| 523 |
-
sendMessage();
|
| 524 |
-
});
|
| 525 |
-
|
| 526 |
dynUpload.addEventListener('click', () => dynFileInput.click());
|
| 527 |
-
dynFileInput.addEventListener('change', () => {
|
| 528 |
-
const file = dynFileInput.files[0];
|
| 529 |
-
if (file) handleImageFile(file);
|
| 530 |
-
});
|
| 531 |
-
|
| 532 |
div.querySelectorAll('.hero-pill').forEach(p => {
|
| 533 |
-
p.addEventListener('click', () => {
|
| 534 |
-
userInput.value = p.dataset.query;
|
| 535 |
-
sendMessage();
|
| 536 |
-
});
|
| 537 |
});
|
| 538 |
-
|
| 539 |
return div;
|
| 540 |
}
|
| 541 |
|
|
@@ -578,8 +521,6 @@ function loadThread(threadId) {
|
|
| 578 |
renderHistory();
|
| 579 |
chatContainer.innerHTML = '';
|
| 580 |
exitHeroMode();
|
| 581 |
-
|
| 582 |
-
// Close sidebar on mobile
|
| 583 |
if (window.innerWidth <= 768) closeSidebar();
|
| 584 |
|
| 585 |
fetch('/history/' + threadId)
|
|
@@ -589,10 +530,7 @@ function loadThread(threadId) {
|
|
| 589 |
const sender = msg.role === 'user' ? 'user' : 'ai';
|
| 590 |
let textContent = msg.content;
|
| 591 |
if (Array.isArray(msg.content)) {
|
| 592 |
-
textContent = msg.content
|
| 593 |
-
.filter(part => part.type === 'text')
|
| 594 |
-
.map(part => part.text)
|
| 595 |
-
.join('');
|
| 596 |
}
|
| 597 |
const el = appendMessage(sender, textContent);
|
| 598 |
if (sender === 'ai') renderFinalContent(el, textContent);
|
|
@@ -606,12 +544,10 @@ function loadThread(threadId) {
|
|
| 606 |
============================================================ */
|
| 607 |
|
| 608 |
function toggleMenu(e, btn) {
|
| 609 |
-
e.stopPropagation();
|
| 610 |
-
closeAllMenus();
|
| 611 |
btn.nextElementSibling.classList.add('show');
|
| 612 |
btn.classList.add('menu-open');
|
| 613 |
}
|
| 614 |
-
|
| 615 |
document.addEventListener('click', closeAllMenus);
|
| 616 |
|
| 617 |
function closeAllMenus() {
|
|
@@ -637,29 +573,20 @@ function renameChat(e, optionEl) {
|
|
| 637 |
const titleSpan = item.querySelector('.chat-title');
|
| 638 |
const threadId = item.getAttribute('data-thread-id');
|
| 639 |
closeAllMenus();
|
| 640 |
-
|
| 641 |
const currentTitle = titleSpan.innerText;
|
| 642 |
const input = document.createElement('input');
|
| 643 |
-
input.type = 'text';
|
| 644 |
-
|
| 645 |
-
input.className = 'rename-input';
|
| 646 |
-
titleSpan.replaceWith(input);
|
| 647 |
-
input.focus();
|
| 648 |
input.selectionStart = input.selectionEnd = input.value.length;
|
| 649 |
|
| 650 |
function saveRename() {
|
| 651 |
const newTitle = input.value.trim() || 'Untitled Chat';
|
| 652 |
-
titleSpan.innerText = newTitle;
|
| 653 |
-
input.replaceWith(titleSpan);
|
| 654 |
const thread = threads.find(t => t.id === threadId);
|
| 655 |
if (thread) thread.title = newTitle;
|
| 656 |
-
fetch('/rename', {
|
| 657 |
-
|
| 658 |
-
headers: { 'Content-Type': 'application/json' },
|
| 659 |
-
body: JSON.stringify({ thread_id: threadId, title: newTitle })
|
| 660 |
-
});
|
| 661 |
}
|
| 662 |
-
|
| 663 |
input.addEventListener('blur', saveRename);
|
| 664 |
input.addEventListener('keydown', evt => {
|
| 665 |
if (evt.key === 'Enter') saveRename();
|
|
@@ -674,28 +601,21 @@ function renameChat(e, optionEl) {
|
|
| 674 |
============================================================ */
|
| 675 |
|
| 676 |
if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => {
|
| 677 |
-
e.stopPropagation();
|
| 678 |
-
userMenu.classList.toggle('show');
|
| 679 |
});
|
| 680 |
-
|
| 681 |
if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => {
|
| 682 |
-
userMenu.classList.remove('show');
|
| 683 |
-
settingsOverlay.classList.add('show');
|
| 684 |
});
|
| 685 |
-
|
| 686 |
if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show'));
|
| 687 |
if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => {
|
| 688 |
if (e.target === settingsOverlay) settingsOverlay.classList.remove('show');
|
| 689 |
});
|
| 690 |
-
|
| 691 |
if (logoutBtn) logoutBtn.addEventListener('click', () => {
|
| 692 |
localStorage.removeItem('stemcopilot_user');
|
| 693 |
localStorage.removeItem('stemcopilot_username');
|
| 694 |
-
currentUser = null;
|
| 695 |
-
location.reload();
|
| 696 |
});
|
| 697 |
|
| 698 |
-
// Settings tabs
|
| 699 |
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
| 700 |
btn.addEventListener('click', () => {
|
| 701 |
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
|
|
@@ -706,19 +626,15 @@ document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
|
| 706 |
});
|
| 707 |
});
|
| 708 |
|
| 709 |
-
// Username
|
| 710 |
if (usernameInput) usernameInput.addEventListener('input', () => {
|
| 711 |
currentUsername = usernameInput.value.trim();
|
| 712 |
localStorage.setItem('stemcopilot_username', currentUsername);
|
| 713 |
});
|
| 714 |
-
|
| 715 |
-
// Language
|
| 716 |
if (languageSelect) languageSelect.addEventListener('change', () => {
|
| 717 |
currentLanguage = languageSelect.value;
|
| 718 |
localStorage.setItem('stemcopilot_language', currentLanguage);
|
| 719 |
});
|
| 720 |
|
| 721 |
-
// Persona selection
|
| 722 |
document.querySelectorAll('.persona-option').forEach(opt => {
|
| 723 |
opt.addEventListener('click', () => {
|
| 724 |
document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
|
|
@@ -728,26 +644,41 @@ document.querySelectorAll('.persona-option').forEach(opt => {
|
|
| 728 |
});
|
| 729 |
});
|
| 730 |
|
| 731 |
-
// Save API key
|
| 732 |
if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
|
| 733 |
const key = settingsApiKeyInput.value.trim();
|
| 734 |
if (!key || key.startsWith('••')) return;
|
| 735 |
if (!currentUser) return;
|
|
|
|
|
|
|
| 736 |
fetch('/user/apikey', {
|
| 737 |
method: 'POST',
|
| 738 |
headers: { 'Content-Type': 'application/json' },
|
| 739 |
body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
|
| 740 |
-
}).then(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
});
|
| 742 |
|
| 743 |
-
// Save student profile
|
| 744 |
if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
|
| 745 |
if (!currentUser) return;
|
| 746 |
fetch('/user/profile', {
|
| 747 |
method: 'POST',
|
| 748 |
headers: { 'Content-Type': 'application/json' },
|
| 749 |
body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }),
|
| 750 |
-
})
|
|
|
|
|
|
|
|
|
|
| 751 |
});
|
| 752 |
|
| 753 |
|
|
@@ -756,27 +687,16 @@ if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
|
|
| 756 |
============================================================ */
|
| 757 |
|
| 758 |
if (uploadBtn) uploadBtn.addEventListener('click', () => imageInput.click());
|
| 759 |
-
|
| 760 |
-
if (imageInput) imageInput.addEventListener('change', () => {
|
| 761 |
-
const file = imageInput.files[0];
|
| 762 |
-
if (file) handleImageFile(file);
|
| 763 |
-
});
|
| 764 |
-
|
| 765 |
if (heroUploadBtn) heroUploadBtn.addEventListener('click', () => heroImageInput.click());
|
| 766 |
-
if (heroImageInput) heroImageInput.addEventListener('change', () => {
|
| 767 |
-
const file = heroImageInput.files[0];
|
| 768 |
-
if (file) handleImageFile(file);
|
| 769 |
-
});
|
| 770 |
|
| 771 |
-
// Paste
|
| 772 |
document.addEventListener('paste', (e) => {
|
| 773 |
const items = e.clipboardData?.items;
|
| 774 |
if (!items) return;
|
| 775 |
for (const item of items) {
|
| 776 |
if (item.type.startsWith('image/')) {
|
| 777 |
-
e.preventDefault();
|
| 778 |
-
handleImageFile(item.getAsFile());
|
| 779 |
-
return;
|
| 780 |
}
|
| 781 |
}
|
| 782 |
});
|
|
@@ -800,81 +720,69 @@ if (imagePreviewRemove) imagePreviewRemove.addEventListener('click', () => {
|
|
| 800 |
|
| 801 |
|
| 802 |
/* ============================================================
|
| 803 |
-
CHAT & STREAMING
|
| 804 |
============================================================ */
|
| 805 |
|
| 806 |
let currentAbortController = null;
|
|
|
|
| 807 |
|
| 808 |
function _showStopBtn() {
|
| 809 |
if (sendBtn) sendBtn.style.display = 'none';
|
| 810 |
-
if (stopBtn) {
|
| 811 |
-
stopBtn.style.display = 'flex';
|
| 812 |
-
stopBtn.classList.add('visible');
|
| 813 |
-
}
|
| 814 |
}
|
| 815 |
-
|
| 816 |
function _showSendBtn() {
|
| 817 |
-
if (stopBtn) {
|
| 818 |
-
stopBtn.style.display = 'none';
|
| 819 |
-
stopBtn.classList.remove('visible');
|
| 820 |
-
}
|
| 821 |
if (sendBtn) sendBtn.style.display = 'flex';
|
| 822 |
}
|
| 823 |
|
| 824 |
if (stopBtn) {
|
| 825 |
stopBtn.addEventListener('click', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
if (currentAbortController) {
|
| 827 |
currentAbortController.abort();
|
| 828 |
currentAbortController = null;
|
| 829 |
}
|
|
|
|
| 830 |
isSending = false;
|
| 831 |
_showSendBtn();
|
| 832 |
const currentThinking = document.getElementById('currentThinking');
|
| 833 |
if (currentThinking) currentThinking.style.display = 'none';
|
| 834 |
document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
|
|
|
|
|
|
|
| 835 |
});
|
| 836 |
}
|
| 837 |
|
| 838 |
if (userInput) {
|
| 839 |
userInput.addEventListener('input', function () {
|
| 840 |
-
this.style.height = '54px';
|
| 841 |
-
this.style.height = this.scrollHeight + 'px';
|
| 842 |
});
|
| 843 |
-
|
| 844 |
userInput.addEventListener('keydown', function (e) {
|
| 845 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
| 846 |
-
e.preventDefault();
|
| 847 |
-
sendMessage();
|
| 848 |
-
}
|
| 849 |
});
|
| 850 |
}
|
| 851 |
|
| 852 |
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
| 853 |
|
| 854 |
-
// Hero pills (static)
|
| 855 |
document.querySelectorAll('.hero-pill').forEach(p => {
|
| 856 |
-
p.addEventListener('click', () => {
|
| 857 |
-
userInput.value = p.dataset.query;
|
| 858 |
-
sendMessage();
|
| 859 |
-
});
|
| 860 |
});
|
| 861 |
|
| 862 |
-
// Hero send (static)
|
| 863 |
if (heroSendBtn) heroSendBtn.addEventListener('click', () => {
|
| 864 |
-
userInput.value = heroInput.value;
|
| 865 |
-
sendMessage();
|
| 866 |
});
|
| 867 |
-
|
| 868 |
if (heroInput) {
|
| 869 |
heroInput.addEventListener('input', function() {
|
| 870 |
-
this.style.height = '54px';
|
| 871 |
-
this.style.height = this.scrollHeight + 'px';
|
| 872 |
});
|
| 873 |
heroInput.addEventListener('keydown', function(e) {
|
| 874 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 875 |
-
e.preventDefault();
|
| 876 |
-
userInput.value = heroInput.value;
|
| 877 |
-
sendMessage();
|
| 878 |
}
|
| 879 |
});
|
| 880 |
}
|
|
@@ -882,9 +790,7 @@ if (heroInput) {
|
|
| 882 |
function sendMessage() {
|
| 883 |
const text = userInput.value.trim();
|
| 884 |
if (!text || isSending) return;
|
| 885 |
-
|
| 886 |
if (isHeroMode) exitHeroMode();
|
| 887 |
-
|
| 888 |
isSending = true;
|
| 889 |
|
| 890 |
if (pendingImage) {
|
|
@@ -899,8 +805,7 @@ function sendMessage() {
|
|
| 899 |
appendMessage('user', text);
|
| 900 |
}
|
| 901 |
|
| 902 |
-
userInput.value = '';
|
| 903 |
-
userInput.style.height = '54px';
|
| 904 |
|
| 905 |
const exists = threads.find(t => t.id === currentThreadId);
|
| 906 |
if (!exists) {
|
|
@@ -908,11 +813,8 @@ function sendMessage() {
|
|
| 908 |
addThreadToSidebar(currentThreadId, title);
|
| 909 |
} else {
|
| 910 |
const idx = threads.indexOf(exists);
|
| 911 |
-
threads.splice(idx, 1);
|
| 912 |
-
threads.unshift(exists);
|
| 913 |
-
renderHistory();
|
| 914 |
}
|
| 915 |
-
|
| 916 |
streamResponse(text);
|
| 917 |
}
|
| 918 |
|
|
@@ -935,7 +837,6 @@ function streamResponse(text) {
|
|
| 935 |
`;
|
| 936 |
chatContainer.appendChild(rowDiv);
|
| 937 |
scrollToBottom();
|
| 938 |
-
|
| 939 |
_showStopBtn();
|
| 940 |
|
| 941 |
const thinkingEl = rowDiv.querySelector('#currentThinking');
|
|
@@ -943,24 +844,21 @@ function streamResponse(text) {
|
|
| 943 |
const contentEl = rowDiv.querySelector('.message-content');
|
| 944 |
let rawText = '';
|
| 945 |
let firstToken = true;
|
|
|
|
| 946 |
|
| 947 |
let renderTimer = null;
|
| 948 |
const RENDER_INTERVAL = 120;
|
| 949 |
-
|
| 950 |
function scheduleRender() {
|
| 951 |
-
if (renderTimer) return;
|
| 952 |
renderTimer = setTimeout(() => {
|
| 953 |
renderTimer = null;
|
| 954 |
-
renderFinalContent(contentEl, rawText);
|
| 955 |
-
scrollToBottom();
|
| 956 |
}, RENDER_INTERVAL);
|
| 957 |
}
|
| 958 |
|
| 959 |
const payload = {
|
| 960 |
-
message: text,
|
| 961 |
-
|
| 962 |
-
persona: currentPersona,
|
| 963 |
-
language: currentLanguage,
|
| 964 |
username: currentUsername,
|
| 965 |
user_id: currentUser ? currentUser.google_id : '',
|
| 966 |
image: imageData,
|
|
@@ -975,13 +873,14 @@ function streamResponse(text) {
|
|
| 975 |
signal: currentAbortController.signal,
|
| 976 |
}).then(response => {
|
| 977 |
const reader = response.body.getReader();
|
|
|
|
| 978 |
const decoder = new TextDecoder();
|
| 979 |
let buffer = '';
|
| 980 |
|
| 981 |
function read() {
|
| 982 |
reader.read().then(({ done, value }) => {
|
| 983 |
-
if (done) {
|
| 984 |
-
finishStream(thinkingEl, contentEl, rawText, renderTimer);
|
| 985 |
return;
|
| 986 |
}
|
| 987 |
|
|
@@ -990,31 +889,22 @@ function streamResponse(text) {
|
|
| 990 |
buffer = lines.pop();
|
| 991 |
|
| 992 |
lines.forEach(line => {
|
|
|
|
| 993 |
if (!line.startsWith('data: ')) return;
|
| 994 |
-
const
|
| 995 |
-
|
| 996 |
-
if (payload === '[DONE]') {
|
| 997 |
finishStream(thinkingEl, contentEl, rawText, renderTimer);
|
| 998 |
-
return;
|
| 999 |
}
|
| 1000 |
-
|
| 1001 |
try {
|
| 1002 |
-
const data = JSON.parse(
|
| 1003 |
-
|
| 1004 |
-
if (data.status === 'thinking') {
|
| 1005 |
-
thinkingText.textContent = data.message;
|
| 1006 |
-
return;
|
| 1007 |
-
}
|
| 1008 |
-
|
| 1009 |
if (data.error) {
|
| 1010 |
thinkingEl.style.display = 'none';
|
| 1011 |
contentEl.style.display = 'block';
|
| 1012 |
contentEl.innerHTML = `<div class="error-message">${escapeHtml(data.error)}</div>`;
|
| 1013 |
-
isSending = false;
|
| 1014 |
-
_showSendBtn();
|
| 1015 |
-
return;
|
| 1016 |
}
|
| 1017 |
-
|
| 1018 |
if (data.token !== undefined) {
|
| 1019 |
if (firstToken) {
|
| 1020 |
thinkingEl.style.display = 'none';
|
|
@@ -1025,29 +915,36 @@ function streamResponse(text) {
|
|
| 1025 |
rawText += data.token;
|
| 1026 |
scheduleRender();
|
| 1027 |
}
|
| 1028 |
-
} catch (
|
| 1029 |
});
|
| 1030 |
|
| 1031 |
-
read();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
});
|
| 1033 |
}
|
| 1034 |
-
|
| 1035 |
read();
|
| 1036 |
-
}).catch(
|
| 1037 |
if (err.name === 'AbortError') {
|
|
|
|
| 1038 |
thinkingEl.style.display = 'none';
|
| 1039 |
contentEl.style.display = 'block';
|
| 1040 |
-
isSending = false;
|
| 1041 |
-
_showSendBtn();
|
| 1042 |
return;
|
| 1043 |
}
|
| 1044 |
thinkingEl.style.display = 'none';
|
| 1045 |
contentEl.style.display = 'block';
|
| 1046 |
contentEl.innerHTML = '<div class="error-message">Could not connect to the server. Please try again.</div>';
|
| 1047 |
-
isSending = false;
|
| 1048 |
-
_showSendBtn();
|
| 1049 |
}).finally(() => {
|
| 1050 |
currentAbortController = null;
|
|
|
|
| 1051 |
const avatar = rowDiv.querySelector('#avatarThinking');
|
| 1052 |
if (avatar) avatar.classList.remove('pulsing');
|
| 1053 |
});
|
|
@@ -1085,9 +982,7 @@ function appendMessage(sender, text) {
|
|
| 1085 |
return rowDiv.querySelector('.message-content');
|
| 1086 |
}
|
| 1087 |
|
| 1088 |
-
function scrollToBottom() {
|
| 1089 |
-
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 1090 |
-
}
|
| 1091 |
|
| 1092 |
function escapeHtml(text) {
|
| 1093 |
if (typeof text !== 'string') return '';
|
|
@@ -1103,37 +998,15 @@ function escapeHtml(text) {
|
|
| 1103 |
|
| 1104 |
function renderFinalContent(element, rawText) {
|
| 1105 |
if (!rawText) return;
|
| 1106 |
-
|
| 1107 |
const blocks = [];
|
| 1108 |
let safeText = rawText;
|
| 1109 |
-
|
| 1110 |
-
safeText = safeText.replace(/\$\$
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
});
|
| 1114 |
-
|
| 1115 |
-
safeText = safeText.replace(/\$[^\$\n]+?\$/g, match => {
|
| 1116 |
-
blocks.push(match);
|
| 1117 |
-
return `%%LATEX_${blocks.length - 1}%%`;
|
| 1118 |
-
});
|
| 1119 |
-
|
| 1120 |
-
safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, match => {
|
| 1121 |
-
blocks.push(match);
|
| 1122 |
-
return `%%LATEX_${blocks.length - 1}%%`;
|
| 1123 |
-
});
|
| 1124 |
-
safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, match => {
|
| 1125 |
-
blocks.push(match);
|
| 1126 |
-
return `%%LATEX_${blocks.length - 1}%%`;
|
| 1127 |
-
});
|
| 1128 |
-
|
| 1129 |
let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
|
| 1130 |
-
|
| 1131 |
-
blocks.forEach((block, i) => {
|
| 1132 |
-
html = html.replace(`%%LATEX_${i}%%`, block);
|
| 1133 |
-
});
|
| 1134 |
-
|
| 1135 |
element.innerHTML = html;
|
| 1136 |
-
|
| 1137 |
if (typeof renderMathInElement !== 'undefined') {
|
| 1138 |
renderMathInElement(element, {
|
| 1139 |
delimiters: [
|
|
@@ -1161,7 +1034,7 @@ let deferredPrompt = null;
|
|
| 1161 |
window.addEventListener('beforeinstallprompt', (e) => {
|
| 1162 |
e.preventDefault();
|
| 1163 |
deferredPrompt = e;
|
| 1164 |
-
// Show install banner
|
| 1165 |
const dismissed = localStorage.getItem('stemcopilot_install_dismissed');
|
| 1166 |
if (!dismissed && installBanner) {
|
| 1167 |
installBanner.style.display = 'flex';
|
|
@@ -1188,6 +1061,9 @@ if (installDismiss) installDismiss.addEventListener('click', () => {
|
|
| 1188 |
INIT
|
| 1189 |
============================================================ */
|
| 1190 |
|
|
|
|
|
|
|
|
|
|
| 1191 |
window.addEventListener('load', () => {
|
| 1192 |
checkExistingSession();
|
| 1193 |
_bindFeedbackSubmit();
|
|
|
|
| 32 |
const welcomeScreen = document.getElementById('welcomeScreen');
|
| 33 |
const bottomInputContainer = document.getElementById('bottomInputContainer');
|
| 34 |
|
|
|
|
| 35 |
const userInput = document.getElementById('userInput');
|
| 36 |
const sendBtn = document.getElementById('sendBtn');
|
| 37 |
const newChatBtn = document.getElementById('newChatBtn');
|
| 38 |
const stopBtn = document.getElementById('stopBtn');
|
| 39 |
|
|
|
|
| 40 |
const heroInput = document.getElementById('heroInput');
|
| 41 |
const heroSendBtn = document.getElementById('heroSendBtn');
|
| 42 |
const heroUploadBtn = document.getElementById('heroUploadBtn');
|
| 43 |
const heroImageInput = document.getElementById('heroImageInput');
|
| 44 |
|
|
|
|
| 45 |
const byokInput = document.getElementById('byokInput');
|
| 46 |
const byokSubmitBtn = document.getElementById('byokSubmitBtn');
|
| 47 |
|
|
|
|
| 48 |
const userProfileBtn = document.getElementById('userProfileBtn');
|
| 49 |
const userMenu = document.getElementById('userMenu');
|
| 50 |
const userAvatar = document.getElementById('userAvatar');
|
| 51 |
const userDisplayName = document.getElementById('userDisplayName');
|
| 52 |
const logoutBtn = document.getElementById('logoutBtn');
|
| 53 |
|
|
|
|
| 54 |
const railExpandBtn = document.getElementById('railExpandBtn');
|
| 55 |
const railNewChatBtn = document.getElementById('railNewChatBtn');
|
| 56 |
const railProfileBtn = document.getElementById('railProfileBtn');
|
| 57 |
const railAvatar = document.getElementById('railAvatar');
|
| 58 |
|
|
|
|
| 59 |
const settingsOverlay = document.getElementById('settingsOverlay');
|
| 60 |
const openSettingsBtn = document.getElementById('openSettingsBtn');
|
| 61 |
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
|
| 62 |
const settingsModal = document.getElementById('settingsModal');
|
|
|
|
| 63 |
const feedbackMenuBtn = document.getElementById('feedbackMenuBtn');
|
| 64 |
|
| 65 |
+
const usernameInput = document.getElementById('usernameInput');
|
| 66 |
+
const languageSelect = document.getElementById('languageSelect');
|
| 67 |
+
const profileInput = document.getElementById('profileInput');
|
| 68 |
+
const saveProfileBtn = document.getElementById('saveProfileBtn');
|
| 69 |
+
const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
|
| 70 |
+
const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
|
| 71 |
+
|
| 72 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 73 |
+
const imageInput = document.getElementById('imageInput');
|
| 74 |
+
const imagePreviewBar = document.getElementById('imagePreviewBar');
|
| 75 |
+
const imagePreviewThumb = document.getElementById('imagePreviewThumb');
|
| 76 |
+
const imagePreviewRemove= document.getElementById('imagePreviewRemove');
|
| 77 |
+
|
| 78 |
+
const installBanner = document.getElementById('installBanner');
|
| 79 |
+
const installBtn = document.getElementById('installBtn');
|
| 80 |
+
const installDismiss = document.getElementById('installDismiss');
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
/* ============================================================
|
| 84 |
+
TOAST NOTIFICATION
|
| 85 |
+
============================================================ */
|
| 86 |
+
function showToast(msg, type = 'info') {
|
| 87 |
+
const t = document.createElement('div');
|
| 88 |
+
t.textContent = msg;
|
| 89 |
+
Object.assign(t.style, {
|
| 90 |
+
position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)',
|
| 91 |
+
background: type === 'error' ? '#ff4a4a' : type === 'success' ? '#2ea44f' : '#333',
|
| 92 |
+
color: '#fff', padding: '10px 20px', borderRadius: '10px', fontSize: '13px',
|
| 93 |
+
fontFamily: 'inherit', zIndex: '9999', boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
| 94 |
+
transition: 'opacity 0.3s', opacity: '0', maxWidth: '90vw', textAlign: 'center',
|
| 95 |
+
});
|
| 96 |
+
document.body.appendChild(t);
|
| 97 |
+
requestAnimationFrame(() => t.style.opacity = '1');
|
| 98 |
+
setTimeout(() => {
|
| 99 |
+
t.style.opacity = '0';
|
| 100 |
+
setTimeout(() => t.remove(), 300);
|
| 101 |
+
}, 2500);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
/* ============================================================
|
| 106 |
+
FEEDBACK
|
| 107 |
+
============================================================ */
|
| 108 |
function openFeedbackInSettings() {
|
| 109 |
userMenu.classList.remove('show');
|
| 110 |
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
|
|
|
|
| 115 |
if (fbTab) fbTab.classList.add('active');
|
| 116 |
settingsOverlay.classList.add('show');
|
| 117 |
}
|
| 118 |
+
if (feedbackMenuBtn) feedbackMenuBtn.addEventListener('click', openFeedbackInSettings);
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
function _bindFeedbackSubmit() {
|
| 121 |
const submitFeedbackBtn = document.getElementById('submitFeedbackBtn');
|
|
|
|
| 124 |
const cat = document.getElementById('feedbackCategory').value;
|
| 125 |
const msg = document.getElementById('feedbackMessage').value;
|
| 126 |
if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; }
|
|
|
|
| 127 |
submitFeedbackBtn.disabled = true;
|
| 128 |
submitFeedbackBtn.textContent = 'Submitting...';
|
|
|
|
| 129 |
fetch('/feedback', {
|
| 130 |
method: 'POST',
|
| 131 |
headers: { 'Content-Type': 'application/json' },
|
| 132 |
body: JSON.stringify({
|
| 133 |
user_id: currentUser ? currentUser.google_id : 'anonymous',
|
| 134 |
+
category: cat, message: msg
|
|
|
|
| 135 |
})
|
| 136 |
+
}).then(r => {
|
| 137 |
+
if (!r.ok) throw new Error('Server error');
|
| 138 |
+
return r.json();
|
| 139 |
+
}).then(() => {
|
| 140 |
settingsOverlay.classList.remove('show');
|
| 141 |
document.getElementById('feedbackMessage').value = '';
|
| 142 |
+
showToast('Feedback submitted. Thank you!', 'success');
|
|
|
|
|
|
|
| 143 |
}).catch(() => {
|
| 144 |
+
showToast('Could not submit feedback. Please try again.', 'error');
|
| 145 |
+
}).finally(() => {
|
| 146 |
submitFeedbackBtn.disabled = false;
|
| 147 |
submitFeedbackBtn.textContent = 'Submit Securely';
|
|
|
|
| 148 |
});
|
| 149 |
});
|
| 150 |
}
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
/* ============================================================
|
| 154 |
AUTH — Google Sign-In
|
|
|
|
| 157 |
let _gsiInitialized = false;
|
| 158 |
|
| 159 |
function initGoogleAuth() {
|
| 160 |
+
return new Promise((resolve) => {
|
| 161 |
if (_gsiInitialized) { resolve(); return; }
|
|
|
|
| 162 |
fetch('/auth/client_id')
|
| 163 |
.then(r => r.json())
|
| 164 |
.then(data => {
|
| 165 |
+
if (!data.client_id) { showApp(); resolve(); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
const script = document.createElement('script');
|
| 167 |
script.src = 'https://accounts.google.com/gsi/client';
|
| 168 |
+
script.async = true; script.defer = true;
|
|
|
|
| 169 |
script.onload = () => {
|
| 170 |
google.accounts.id.initialize({
|
| 171 |
client_id: data.client_id,
|
| 172 |
callback: handleGoogleCredential,
|
| 173 |
+
auto_select: false, cancel_on_tap_outside: false,
|
|
|
|
| 174 |
});
|
| 175 |
_renderHiddenGoogleBtn();
|
| 176 |
_gsiInitialized = true;
|
| 177 |
resolve();
|
| 178 |
};
|
| 179 |
+
script.onerror = () => { showApp(); resolve(); };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
document.head.appendChild(script);
|
| 181 |
})
|
| 182 |
.catch(() => { showApp(); resolve(); });
|
|
|
|
| 188 |
if (!container) return;
|
| 189 |
container.innerHTML = '';
|
| 190 |
google.accounts.id.renderButton(container, {
|
| 191 |
+
type: 'standard', theme: 'filled_black', size: 'large',
|
| 192 |
+
text: 'signin_with', shape: 'pill', width: 240,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
});
|
| 194 |
+
setTimeout(() => { container.style.pointerEvents = 'auto'; if (cb) cb(); }, 150);
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
function triggerGoogleSignIn() {
|
| 198 |
const tryClick = () => {
|
| 199 |
const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
|
| 200 |
+
if (realBtn) { realBtn.click(); }
|
| 201 |
+
else { _renderHiddenGoogleBtn(() => {
|
| 202 |
+
const btn = document.querySelector('#gsi-hidden-btn [role="button"]');
|
| 203 |
+
if (btn) btn.click();
|
| 204 |
+
}); }
|
|
|
|
|
|
|
|
|
|
| 205 |
};
|
| 206 |
+
if (_gsiInitialized) tryClick();
|
| 207 |
+
else initGoogleAuth().then(tryClick);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
function handleGoogleCredential(response) {
|
|
|
|
| 213 |
headers: { 'Content-Type': 'application/json' },
|
| 214 |
body: JSON.stringify({ token: response.credential }),
|
| 215 |
})
|
| 216 |
+
.then(r => {
|
| 217 |
+
if (!r.ok) throw new Error('Auth failed');
|
| 218 |
+
return r.json();
|
| 219 |
+
})
|
| 220 |
.then(data => {
|
| 221 |
+
if (data.error) { showToast('Login failed: ' + data.error, 'error'); return; }
|
| 222 |
currentUser = data.user;
|
| 223 |
localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
|
| 224 |
currentUsername = currentUser.name;
|
| 225 |
localStorage.setItem('stemcopilot_username', currentUsername);
|
| 226 |
+
if (!data.has_api_key) showByok(); else showApp();
|
| 227 |
})
|
| 228 |
+
.catch(() => showToast('Login failed. Check your connection and try again.', 'error'));
|
| 229 |
}
|
| 230 |
|
| 231 |
function checkExistingSession() {
|
|
|
|
| 243 |
return;
|
| 244 |
}
|
| 245 |
currentUser = data.user;
|
| 246 |
+
if (!data.has_api_key) showByok(); else showApp();
|
| 247 |
})
|
| 248 |
.catch(() => initGoogleAuth());
|
| 249 |
} else {
|
|
|
|
| 270 |
headers: { 'Content-Type': 'application/json' },
|
| 271 |
body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
|
| 272 |
})
|
| 273 |
+
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
|
| 274 |
.then(() => showApp())
|
| 275 |
+
.catch(() => showToast('Failed to save key. Try again.', 'error'));
|
| 276 |
});
|
| 277 |
|
| 278 |
|
|
|
|
| 304 |
if (usernameInput) usernameInput.value = currentUsername;
|
| 305 |
if (languageSelect) languageSelect.value = currentLanguage;
|
| 306 |
|
|
|
|
| 307 |
document.querySelectorAll('.persona-option').forEach(opt => {
|
| 308 |
opt.classList.toggle('active', opt.dataset.persona === currentPersona);
|
| 309 |
});
|
| 310 |
|
|
|
|
| 311 |
const userId = currentUser ? currentUser.google_id : '';
|
| 312 |
fetch('/threads?user_id=' + encodeURIComponent(userId))
|
| 313 |
.then(r => r.json())
|
|
|
|
| 317 |
})
|
| 318 |
.catch(() => {});
|
| 319 |
|
| 320 |
+
// Ensure sidebar starts collapsed on mobile
|
| 321 |
+
if (window.innerWidth <= 768) closeSidebar();
|
|
|
|
|
|
|
| 322 |
|
| 323 |
enterHeroMode();
|
| 324 |
}
|
|
|
|
| 348 |
|
| 349 |
|
| 350 |
/* ============================================================
|
| 351 |
+
SIDEBAR
|
| 352 |
============================================================ */
|
| 353 |
|
| 354 |
let sidebarOpen = false;
|
| 355 |
|
| 356 |
function _cleanSidebarStyles() {
|
|
|
|
| 357 |
sidebar.style.transition = '';
|
| 358 |
sidebar.style.transform = '';
|
| 359 |
sidebar.style.opacity = '';
|
|
|
|
| 369 |
sidebarOpen = true;
|
| 370 |
_cleanSidebarStyles();
|
| 371 |
sidebar.classList.remove('collapsed');
|
| 372 |
+
if (sidebarOverlay && window.innerWidth <= 768) sidebarOverlay.classList.add('visible');
|
|
|
|
|
|
|
| 373 |
if (sidebarRail) sidebarRail.classList.remove('visible');
|
| 374 |
}
|
| 375 |
|
|
|
|
| 378 |
_cleanSidebarStyles();
|
| 379 |
sidebar.classList.add('collapsed');
|
| 380 |
if (sidebarOverlay) sidebarOverlay.classList.remove('visible');
|
| 381 |
+
if (sidebarRail && window.innerWidth > 768) sidebarRail.classList.add('visible');
|
|
|
|
|
|
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
function toggleSidebar() {
|
| 385 |
+
if (sidebarOpen) closeSidebar(); else openSidebar();
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
+
if (toggleSidebarBtn) toggleSidebarBtn.addEventListener('click', toggleSidebar);
|
|
|
|
|
|
|
|
|
|
| 389 |
|
|
|
|
| 390 |
if (sidebarOverlay) {
|
| 391 |
sidebarOverlay.addEventListener('click', closeSidebar);
|
| 392 |
sidebarOverlay.addEventListener('touchstart', (e) => {
|
| 393 |
+
e.preventDefault(); closeSidebar();
|
|
|
|
| 394 |
}, { passive: false });
|
| 395 |
}
|
| 396 |
|
| 397 |
+
// Edge swipe — 60px zone, mid-screen horizontal swipe
|
| 398 |
+
let _touchStartX = 0, _touchStartY = 0;
|
|
|
|
| 399 |
document.addEventListener('touchstart', (e) => {
|
| 400 |
if (window.innerWidth > 768) return;
|
| 401 |
_touchStartX = e.changedTouches[0].clientX;
|
|
|
|
| 406 |
if (window.innerWidth > 768) return;
|
| 407 |
const dx = e.changedTouches[0].clientX - _touchStartX;
|
| 408 |
const dy = Math.abs(e.changedTouches[0].clientY - _touchStartY);
|
| 409 |
+
if (dy > Math.abs(dx) * 0.8) return; // too vertical
|
| 410 |
+
// Swipe right from left 60px edge → open
|
| 411 |
+
if (!sidebarOpen && _touchStartX < 60 && dx > 50) openSidebar();
|
| 412 |
+
// Swipe left anywhere → close
|
| 413 |
+
if (sidebarOpen && dx < -50) closeSidebar();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
}, { passive: true });
|
| 415 |
|
|
|
|
| 416 |
if (railExpandBtn) railExpandBtn.addEventListener('click', openSidebar);
|
| 417 |
if (railNewChatBtn) railNewChatBtn.addEventListener('click', () => startNewChat());
|
| 418 |
if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
|
|
|
|
| 420 |
setTimeout(() => { if (userProfileBtn) userProfileBtn.click(); }, 150);
|
| 421 |
});
|
| 422 |
|
|
|
|
| 423 |
const mobileFabToggle = document.getElementById('mobileFabToggle');
|
| 424 |
+
if (mobileFabToggle) mobileFabToggle.addEventListener('click', toggleSidebar);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
if (newChatBtn) newChatBtn.addEventListener('click', startNewChat);
|
| 426 |
|
| 427 |
function startNewChat() {
|
|
|
|
| 430 |
chatContainer.appendChild(createWelcomeScreen());
|
| 431 |
enterHeroMode();
|
| 432 |
renderHistory();
|
|
|
|
| 433 |
if (window.innerWidth <= 768) closeSidebar();
|
| 434 |
}
|
| 435 |
|
|
|
|
| 466 |
const dynFileInput = div.querySelector('#heroImageInputDynamic');
|
| 467 |
|
| 468 |
dynInput.addEventListener('input', function() {
|
| 469 |
+
this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
|
|
|
|
| 470 |
});
|
|
|
|
| 471 |
dynInput.addEventListener('keydown', function(e) {
|
| 472 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 473 |
+
e.preventDefault(); userInput.value = dynInput.value; sendMessage();
|
|
|
|
|
|
|
| 474 |
}
|
| 475 |
});
|
| 476 |
+
dynSend.addEventListener('click', () => { userInput.value = dynInput.value; sendMessage(); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
dynUpload.addEventListener('click', () => dynFileInput.click());
|
| 478 |
+
dynFileInput.addEventListener('change', () => { if (dynFileInput.files[0]) handleImageFile(dynFileInput.files[0]); });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
div.querySelectorAll('.hero-pill').forEach(p => {
|
| 480 |
+
p.addEventListener('click', () => { userInput.value = p.dataset.query; sendMessage(); });
|
|
|
|
|
|
|
|
|
|
| 481 |
});
|
|
|
|
| 482 |
return div;
|
| 483 |
}
|
| 484 |
|
|
|
|
| 521 |
renderHistory();
|
| 522 |
chatContainer.innerHTML = '';
|
| 523 |
exitHeroMode();
|
|
|
|
|
|
|
| 524 |
if (window.innerWidth <= 768) closeSidebar();
|
| 525 |
|
| 526 |
fetch('/history/' + threadId)
|
|
|
|
| 530 |
const sender = msg.role === 'user' ? 'user' : 'ai';
|
| 531 |
let textContent = msg.content;
|
| 532 |
if (Array.isArray(msg.content)) {
|
| 533 |
+
textContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('');
|
|
|
|
|
|
|
|
|
|
| 534 |
}
|
| 535 |
const el = appendMessage(sender, textContent);
|
| 536 |
if (sender === 'ai') renderFinalContent(el, textContent);
|
|
|
|
| 544 |
============================================================ */
|
| 545 |
|
| 546 |
function toggleMenu(e, btn) {
|
| 547 |
+
e.stopPropagation(); closeAllMenus();
|
|
|
|
| 548 |
btn.nextElementSibling.classList.add('show');
|
| 549 |
btn.classList.add('menu-open');
|
| 550 |
}
|
|
|
|
| 551 |
document.addEventListener('click', closeAllMenus);
|
| 552 |
|
| 553 |
function closeAllMenus() {
|
|
|
|
| 573 |
const titleSpan = item.querySelector('.chat-title');
|
| 574 |
const threadId = item.getAttribute('data-thread-id');
|
| 575 |
closeAllMenus();
|
|
|
|
| 576 |
const currentTitle = titleSpan.innerText;
|
| 577 |
const input = document.createElement('input');
|
| 578 |
+
input.type = 'text'; input.value = currentTitle; input.className = 'rename-input';
|
| 579 |
+
titleSpan.replaceWith(input); input.focus();
|
|
|
|
|
|
|
|
|
|
| 580 |
input.selectionStart = input.selectionEnd = input.value.length;
|
| 581 |
|
| 582 |
function saveRename() {
|
| 583 |
const newTitle = input.value.trim() || 'Untitled Chat';
|
| 584 |
+
titleSpan.innerText = newTitle; input.replaceWith(titleSpan);
|
|
|
|
| 585 |
const thread = threads.find(t => t.id === threadId);
|
| 586 |
if (thread) thread.title = newTitle;
|
| 587 |
+
fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 588 |
+
body: JSON.stringify({ thread_id: threadId, title: newTitle }) });
|
|
|
|
|
|
|
|
|
|
| 589 |
}
|
|
|
|
| 590 |
input.addEventListener('blur', saveRename);
|
| 591 |
input.addEventListener('keydown', evt => {
|
| 592 |
if (evt.key === 'Enter') saveRename();
|
|
|
|
| 601 |
============================================================ */
|
| 602 |
|
| 603 |
if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => {
|
| 604 |
+
e.stopPropagation(); userMenu.classList.toggle('show');
|
|
|
|
| 605 |
});
|
|
|
|
| 606 |
if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => {
|
| 607 |
+
userMenu.classList.remove('show'); settingsOverlay.classList.add('show');
|
|
|
|
| 608 |
});
|
|
|
|
| 609 |
if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show'));
|
| 610 |
if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => {
|
| 611 |
if (e.target === settingsOverlay) settingsOverlay.classList.remove('show');
|
| 612 |
});
|
|
|
|
| 613 |
if (logoutBtn) logoutBtn.addEventListener('click', () => {
|
| 614 |
localStorage.removeItem('stemcopilot_user');
|
| 615 |
localStorage.removeItem('stemcopilot_username');
|
| 616 |
+
currentUser = null; location.reload();
|
|
|
|
| 617 |
});
|
| 618 |
|
|
|
|
| 619 |
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
| 620 |
btn.addEventListener('click', () => {
|
| 621 |
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
|
|
|
|
| 626 |
});
|
| 627 |
});
|
| 628 |
|
|
|
|
| 629 |
if (usernameInput) usernameInput.addEventListener('input', () => {
|
| 630 |
currentUsername = usernameInput.value.trim();
|
| 631 |
localStorage.setItem('stemcopilot_username', currentUsername);
|
| 632 |
});
|
|
|
|
|
|
|
| 633 |
if (languageSelect) languageSelect.addEventListener('change', () => {
|
| 634 |
currentLanguage = languageSelect.value;
|
| 635 |
localStorage.setItem('stemcopilot_language', currentLanguage);
|
| 636 |
});
|
| 637 |
|
|
|
|
| 638 |
document.querySelectorAll('.persona-option').forEach(opt => {
|
| 639 |
opt.addEventListener('click', () => {
|
| 640 |
document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
|
|
|
|
| 644 |
});
|
| 645 |
});
|
| 646 |
|
| 647 |
+
// Save API key — with success/error feedback
|
| 648 |
if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
|
| 649 |
const key = settingsApiKeyInput.value.trim();
|
| 650 |
if (!key || key.startsWith('••')) return;
|
| 651 |
if (!currentUser) return;
|
| 652 |
+
saveApiKeyBtn.disabled = true;
|
| 653 |
+
saveApiKeyBtn.textContent = 'Saving...';
|
| 654 |
fetch('/user/apikey', {
|
| 655 |
method: 'POST',
|
| 656 |
headers: { 'Content-Type': 'application/json' },
|
| 657 |
body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
|
| 658 |
+
}).then(r => {
|
| 659 |
+
if (!r.ok) throw new Error();
|
| 660 |
+
return r.json();
|
| 661 |
+
}).then(() => {
|
| 662 |
+
settingsApiKeyInput.value = '••••••••••••';
|
| 663 |
+
showToast('API key saved successfully!', 'success');
|
| 664 |
+
}).catch(() => {
|
| 665 |
+
showToast('Failed to save API key. Please try again.', 'error');
|
| 666 |
+
}).finally(() => {
|
| 667 |
+
saveApiKeyBtn.disabled = false;
|
| 668 |
+
saveApiKeyBtn.textContent = 'Save Key';
|
| 669 |
+
});
|
| 670 |
});
|
| 671 |
|
|
|
|
| 672 |
if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
|
| 673 |
if (!currentUser) return;
|
| 674 |
fetch('/user/profile', {
|
| 675 |
method: 'POST',
|
| 676 |
headers: { 'Content-Type': 'application/json' },
|
| 677 |
body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }),
|
| 678 |
+
}).then(r => {
|
| 679 |
+
if (!r.ok) throw new Error();
|
| 680 |
+
showToast('Profile saved!', 'success');
|
| 681 |
+
}).catch(() => showToast('Failed to save profile.', 'error'));
|
| 682 |
});
|
| 683 |
|
| 684 |
|
|
|
|
| 687 |
============================================================ */
|
| 688 |
|
| 689 |
if (uploadBtn) uploadBtn.addEventListener('click', () => imageInput.click());
|
| 690 |
+
if (imageInput) imageInput.addEventListener('change', () => { if (imageInput.files[0]) handleImageFile(imageInput.files[0]); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
if (heroUploadBtn) heroUploadBtn.addEventListener('click', () => heroImageInput.click());
|
| 692 |
+
if (heroImageInput) heroImageInput.addEventListener('change', () => { if (heroImageInput.files[0]) handleImageFile(heroImageInput.files[0]); });
|
|
|
|
|
|
|
|
|
|
| 693 |
|
|
|
|
| 694 |
document.addEventListener('paste', (e) => {
|
| 695 |
const items = e.clipboardData?.items;
|
| 696 |
if (!items) return;
|
| 697 |
for (const item of items) {
|
| 698 |
if (item.type.startsWith('image/')) {
|
| 699 |
+
e.preventDefault(); handleImageFile(item.getAsFile()); return;
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
}
|
| 702 |
});
|
|
|
|
| 720 |
|
| 721 |
|
| 722 |
/* ============================================================
|
| 723 |
+
CHAT & STREAMING — with REAL stop button
|
| 724 |
============================================================ */
|
| 725 |
|
| 726 |
let currentAbortController = null;
|
| 727 |
+
let currentStreamReader = null; // ← key: we keep a ref to cancel it
|
| 728 |
|
| 729 |
function _showStopBtn() {
|
| 730 |
if (sendBtn) sendBtn.style.display = 'none';
|
| 731 |
+
if (stopBtn) { stopBtn.style.display = 'flex'; stopBtn.classList.add('visible'); }
|
|
|
|
|
|
|
|
|
|
| 732 |
}
|
|
|
|
| 733 |
function _showSendBtn() {
|
| 734 |
+
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('visible'); }
|
|
|
|
|
|
|
|
|
|
| 735 |
if (sendBtn) sendBtn.style.display = 'flex';
|
| 736 |
}
|
| 737 |
|
| 738 |
if (stopBtn) {
|
| 739 |
stopBtn.addEventListener('click', () => {
|
| 740 |
+
// 1) Cancel the stream reader so no more tokens arrive
|
| 741 |
+
if (currentStreamReader) {
|
| 742 |
+
try { currentStreamReader.cancel(); } catch(_) {}
|
| 743 |
+
currentStreamReader = null;
|
| 744 |
+
}
|
| 745 |
+
// 2) Abort the fetch connection so server detects disconnect
|
| 746 |
if (currentAbortController) {
|
| 747 |
currentAbortController.abort();
|
| 748 |
currentAbortController = null;
|
| 749 |
}
|
| 750 |
+
// 3) Reset UI state
|
| 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 |
+
// 4) Remove the typing cursor from partial response
|
| 757 |
+
document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
|
| 758 |
});
|
| 759 |
}
|
| 760 |
|
| 761 |
if (userInput) {
|
| 762 |
userInput.addEventListener('input', function () {
|
| 763 |
+
this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
|
|
|
|
| 764 |
});
|
|
|
|
| 765 |
userInput.addEventListener('keydown', function (e) {
|
| 766 |
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
|
|
|
|
|
|
|
|
|
| 767 |
});
|
| 768 |
}
|
| 769 |
|
| 770 |
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
| 771 |
|
|
|
|
| 772 |
document.querySelectorAll('.hero-pill').forEach(p => {
|
| 773 |
+
p.addEventListener('click', () => { userInput.value = p.dataset.query; sendMessage(); });
|
|
|
|
|
|
|
|
|
|
| 774 |
});
|
| 775 |
|
|
|
|
| 776 |
if (heroSendBtn) heroSendBtn.addEventListener('click', () => {
|
| 777 |
+
userInput.value = heroInput.value; sendMessage();
|
|
|
|
| 778 |
});
|
|
|
|
| 779 |
if (heroInput) {
|
| 780 |
heroInput.addEventListener('input', function() {
|
| 781 |
+
this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
|
|
|
|
| 782 |
});
|
| 783 |
heroInput.addEventListener('keydown', function(e) {
|
| 784 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 785 |
+
e.preventDefault(); userInput.value = heroInput.value; sendMessage();
|
|
|
|
|
|
|
| 786 |
}
|
| 787 |
});
|
| 788 |
}
|
|
|
|
| 790 |
function sendMessage() {
|
| 791 |
const text = userInput.value.trim();
|
| 792 |
if (!text || isSending) return;
|
|
|
|
| 793 |
if (isHeroMode) exitHeroMode();
|
|
|
|
| 794 |
isSending = true;
|
| 795 |
|
| 796 |
if (pendingImage) {
|
|
|
|
| 805 |
appendMessage('user', text);
|
| 806 |
}
|
| 807 |
|
| 808 |
+
userInput.value = ''; userInput.style.height = '54px';
|
|
|
|
| 809 |
|
| 810 |
const exists = threads.find(t => t.id === currentThreadId);
|
| 811 |
if (!exists) {
|
|
|
|
| 813 |
addThreadToSidebar(currentThreadId, title);
|
| 814 |
} else {
|
| 815 |
const idx = threads.indexOf(exists);
|
| 816 |
+
threads.splice(idx, 1); threads.unshift(exists); renderHistory();
|
|
|
|
|
|
|
| 817 |
}
|
|
|
|
| 818 |
streamResponse(text);
|
| 819 |
}
|
| 820 |
|
|
|
|
| 837 |
`;
|
| 838 |
chatContainer.appendChild(rowDiv);
|
| 839 |
scrollToBottom();
|
|
|
|
| 840 |
_showStopBtn();
|
| 841 |
|
| 842 |
const thinkingEl = rowDiv.querySelector('#currentThinking');
|
|
|
|
| 844 |
const contentEl = rowDiv.querySelector('.message-content');
|
| 845 |
let rawText = '';
|
| 846 |
let firstToken = true;
|
| 847 |
+
let stopped = false;
|
| 848 |
|
| 849 |
let renderTimer = null;
|
| 850 |
const RENDER_INTERVAL = 120;
|
|
|
|
| 851 |
function scheduleRender() {
|
| 852 |
+
if (renderTimer || stopped) return;
|
| 853 |
renderTimer = setTimeout(() => {
|
| 854 |
renderTimer = null;
|
| 855 |
+
if (!stopped) { renderFinalContent(contentEl, rawText); scrollToBottom(); }
|
|
|
|
| 856 |
}, RENDER_INTERVAL);
|
| 857 |
}
|
| 858 |
|
| 859 |
const payload = {
|
| 860 |
+
message: text, thread_id: currentThreadId,
|
| 861 |
+
persona: currentPersona, language: currentLanguage,
|
|
|
|
|
|
|
| 862 |
username: currentUsername,
|
| 863 |
user_id: currentUser ? currentUser.google_id : '',
|
| 864 |
image: imageData,
|
|
|
|
| 873 |
signal: currentAbortController.signal,
|
| 874 |
}).then(response => {
|
| 875 |
const reader = response.body.getReader();
|
| 876 |
+
currentStreamReader = reader; // ← store so stop button can cancel it
|
| 877 |
const decoder = new TextDecoder();
|
| 878 |
let buffer = '';
|
| 879 |
|
| 880 |
function read() {
|
| 881 |
reader.read().then(({ done, value }) => {
|
| 882 |
+
if (done || stopped) {
|
| 883 |
+
if (!stopped) finishStream(thinkingEl, contentEl, rawText, renderTimer);
|
| 884 |
return;
|
| 885 |
}
|
| 886 |
|
|
|
|
| 889 |
buffer = lines.pop();
|
| 890 |
|
| 891 |
lines.forEach(line => {
|
| 892 |
+
if (stopped) return;
|
| 893 |
if (!line.startsWith('data: ')) return;
|
| 894 |
+
const pl = line.substring(6);
|
| 895 |
+
if (pl === '[DONE]') {
|
|
|
|
| 896 |
finishStream(thinkingEl, contentEl, rawText, renderTimer);
|
| 897 |
+
stopped = true; return;
|
| 898 |
}
|
|
|
|
| 899 |
try {
|
| 900 |
+
const data = JSON.parse(pl);
|
| 901 |
+
if (data.status === 'thinking') { thinkingText.textContent = data.message; return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(); stopped = true; return;
|
|
|
|
|
|
|
| 907 |
}
|
|
|
|
| 908 |
if (data.token !== undefined) {
|
| 909 |
if (firstToken) {
|
| 910 |
thinkingEl.style.display = 'none';
|
|
|
|
| 915 |
rawText += data.token;
|
| 916 |
scheduleRender();
|
| 917 |
}
|
| 918 |
+
} catch (_) {}
|
| 919 |
});
|
| 920 |
|
| 921 |
+
if (!stopped) read();
|
| 922 |
+
}).catch(err => {
|
| 923 |
+
// reader.read() rejected — happens on abort/cancel
|
| 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>';
|
| 929 |
+
isSending = false; _showSendBtn();
|
| 930 |
});
|
| 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';
|
| 938 |
+
isSending = false; _showSendBtn();
|
|
|
|
| 939 |
return;
|
| 940 |
}
|
| 941 |
thinkingEl.style.display = 'none';
|
| 942 |
contentEl.style.display = 'block';
|
| 943 |
contentEl.innerHTML = '<div class="error-message">Could not connect to the server. Please try again.</div>';
|
| 944 |
+
isSending = false; _showSendBtn();
|
|
|
|
| 945 |
}).finally(() => {
|
| 946 |
currentAbortController = null;
|
| 947 |
+
currentStreamReader = null;
|
| 948 |
const avatar = rowDiv.querySelector('#avatarThinking');
|
| 949 |
if (avatar) avatar.classList.remove('pulsing');
|
| 950 |
});
|
|
|
|
| 982 |
return rowDiv.querySelector('.message-content');
|
| 983 |
}
|
| 984 |
|
| 985 |
+
function scrollToBottom() { chatContainer.scrollTop = chatContainer.scrollHeight; }
|
|
|
|
|
|
|
| 986 |
|
| 987 |
function escapeHtml(text) {
|
| 988 |
if (typeof text !== 'string') return '';
|
|
|
|
| 998 |
|
| 999 |
function renderFinalContent(element, rawText) {
|
| 1000 |
if (!rawText) return;
|
|
|
|
| 1001 |
const blocks = [];
|
| 1002 |
let safeText = rawText;
|
| 1003 |
+
safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
|
| 1004 |
+
safeText = safeText.replace(/\$[^\$\n]+?\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
|
| 1005 |
+
safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
|
| 1006 |
+
safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
|
| 1008 |
+
blocks.forEach((block, i) => { html = html.replace(`%%LATEX_${i}%%`, block); });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
element.innerHTML = html;
|
|
|
|
| 1010 |
if (typeof renderMathInElement !== 'undefined') {
|
| 1011 |
renderMathInElement(element, {
|
| 1012 |
delimiters: [
|
|
|
|
| 1034 |
window.addEventListener('beforeinstallprompt', (e) => {
|
| 1035 |
e.preventDefault();
|
| 1036 |
deferredPrompt = e;
|
| 1037 |
+
// Show install banner immediately — works on login screen too
|
| 1038 |
const dismissed = localStorage.getItem('stemcopilot_install_dismissed');
|
| 1039 |
if (!dismissed && installBanner) {
|
| 1040 |
installBanner.style.display = 'flex';
|
|
|
|
| 1061 |
INIT
|
| 1062 |
============================================================ */
|
| 1063 |
|
| 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();
|
static/index.html
CHANGED
|
@@ -81,7 +81,7 @@
|
|
| 81 |
<div class="app-container" id="appContainer" style="display:none;">
|
| 82 |
|
| 83 |
<!-- Sidebar (full) -->
|
| 84 |
-
<aside class="sidebar" id="sidebar">
|
| 85 |
<div class="sidebar-top">
|
| 86 |
<div class="sidebar-logo-row">
|
| 87 |
<img src="/assets/stembotix.png" alt="STEMbotix" class="sidebar-logo">
|
|
|
|
| 81 |
<div class="app-container" id="appContainer" style="display:none;">
|
| 82 |
|
| 83 |
<!-- Sidebar (full) -->
|
| 84 |
+
<aside class="sidebar collapsed" id="sidebar">
|
| 85 |
<div class="sidebar-top">
|
| 86 |
<div class="sidebar-logo-row">
|
| 87 |
<img src="/assets/stembotix.png" alt="STEMbotix" class="sidebar-logo">
|