From d974aaedc8dfca159c9a5ceff409f7defc671447 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 29 Aug 2026 17:28:50 +0200 Subject: [PATCH] Improve deployment safety and chat reliability --- README.md | 24 +++- backend/app/main.py | 84 ++++++++++- backend/app/routers/voice.py | 6 +- backend/app/schemas.py | 4 + backend/tests/test_startup.py | 166 ++++++++++++++++++++++ deploy/fluentgerman.service | 2 +- deploy/setup.sh | 58 ++++++-- frontend/admin.html | 10 +- frontend/chat.html | 13 +- frontend/css/style.css | 9 ++ frontend/index.html | 6 +- frontend/js/admin.js | 69 ++++++--- frontend/js/api.js | 24 +++- frontend/js/chat.js | 256 +++++++++++++++++++++++++--------- frontend/js/voice.js | 168 ++++++++++++++++++---- 15 files changed, 746 insertions(+), 153 deletions(-) create mode 100644 backend/tests/test_startup.py diff --git a/README.md b/README.md index ef7f00f..a2adb64 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A web-based tool for a German language teacher to provide clients with customize | Layer | Technology | |-------|-----------| | Backend | Python 3.12 + FastAPI | -| Database | PostgreSQL (async via SQLAlchemy + asyncpg) | +| Database | MySQL (async via SQLAlchemy + aiomysql) | | Auth | JWT + bcrypt | | LLM | LiteLLM (provider-agnostic) | | Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) | @@ -47,12 +47,26 @@ uvicorn app.main:app --reload ## Production Deployment (Debian) ```bash -# Prerequisites: PostgreSQL and nginx already installed +# Prerequisites: MySQL and nginx already installed sudo bash deploy/setup.sh +``` -# Then edit the .env file: +On a **fresh install** the script generates the database password and writes it +into `.env`, then installs the service *without starting it* — so the app never +goes live with the template's LLM key and default admin password: + +```bash +# 1. Set LLM_API_KEY and ADMIN_PASSWORD (leave DATABASE_URL alone): sudo nano /opt/fluentgerman/backend/.env +# 2. Start it: +sudo systemctl start fluentgerman +``` + +Re-running `setup.sh` on an **existing install** keeps `.env` and the database +password untouched, and restarts the service. + +```bash # Restart after config changes: sudo systemctl restart fluentgerman ``` @@ -64,7 +78,7 @@ sudo systemctl restart fluentgerman │ ├── app/ │ │ ├── main.py # FastAPI entry point │ │ ├── config.py # Environment-based settings -│ │ ├── database.py # Async PostgreSQL setup +│ │ ├── database.py # Async MySQL setup │ │ ├── models.py # User & Instruction models │ │ ├── schemas.py # Pydantic request/response │ │ ├── auth.py # JWT + bcrypt + dependencies @@ -97,7 +111,7 @@ All settings via `.env` file (see `.env.example`): | Variable | Description | |----------|-------------| | `SECRET_KEY` | JWT signing key (generate a strong random one) | -| `DATABASE_URL` | PostgreSQL connection string | +| `DATABASE_URL` | MySQL connection string | | `LLM_API_KEY` | Your LLM provider API key | | `LLM_MODEL` | Model to use (e.g. `gpt-4o-mini`, `claude-3-haiku-20240307`) | | `VOICE_MODE` | `api` (OpenAI Whisper/TTS) or `browser` (Web Speech API) | diff --git a/backend/app/main.py b/backend/app/main.py index 0fbc309..c6f665f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ """FluentGerman.ai — FastAPI application entry point.""" +import asyncio import logging import time from contextlib import asynccontextmanager @@ -7,7 +8,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from sqlalchemy import select +from sqlalchemy import inspect, select +from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError from app.auth import hash_password from app.config import get_settings @@ -30,11 +32,68 @@ logging.basicConfig( logger = logging.getLogger("fluentgerman") +# A worker that loses the create_all race has to wait for the winner to finish +# creating the remaining tables before it can judge the schema complete. +SCHEMA_RETRIES = 10 +SCHEMA_RETRY_DELAY = 0.5 + + +def _is_table_already_exists(exc: Exception) -> bool: + """True only for the specific DDL error that means another worker won. + + Anything else — bad credentials, missing privileges, an unreachable + server — must not be mistaken for a race. + """ + orig = getattr(exc, "orig", None) + args = getattr(orig, "args", ()) + if args and args[0] == 1050: # MySQL ER_TABLE_EXISTS_ERROR + return True + return "already exists" in str(orig or exc).lower() + + +async def _ensure_schema() -> None: + """Create the tables and refuse to continue on a half-built schema. + + An 'already exists' error aborts create_all part-way, so the losing worker + may still be missing tables the winner has not created yet. Retry rather + than treating that moment as a broken database. + """ + missing: list[str] = [] + + for attempt in range(SCHEMA_RETRIES): + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + except (IntegrityError, OperationalError, ProgrammingError) as exc: + if not _is_table_already_exists(exc): + raise + logger.warning("Schema creation raced with another worker: %s", exc) + + async with engine.connect() as conn: + present = await conn.run_sync(lambda c: set(inspect(c).get_table_names())) + + missing = sorted(set(Base.metadata.tables) - present) + if not missing: + return + + logger.info( + "Waiting for another worker to finish creating %s (attempt %d/%d)", + missing, attempt + 1, SCHEMA_RETRIES, + ) + await asyncio.sleep(SCHEMA_RETRY_DELAY) + + raise RuntimeError(f"Database schema is incomplete — missing tables: {missing}") + + @asynccontextmanager async def lifespan(app: FastAPI): - """Create tables and bootstrap admin user on startup.""" - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + """Create tables and bootstrap admin user on startup. + + uvicorn runs this once per worker and the unit file starts two, so on an + empty database both can reach the same check at the same moment. Both + steps below are written to tolerate losing that race. + """ + await _ensure_schema() # Bootstrap admin if not exists settings = get_settings() @@ -48,8 +107,21 @@ async def lifespan(app: FastAPI): is_admin=True, ) db.add(admin) - await db.commit() - logger.info("Admin user created: %s", settings.admin_username) + try: + await db.commit() + logger.info("Admin user created: %s", settings.admin_username) + except IntegrityError: + # Either another worker inserted the same admin, or the + # configured username/email is taken by a non-admin account. + await db.rollback() + result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712 + if result.scalar_one_or_none() is None: + logger.error( + "Cannot create admin '%s': the username or email already " + "belongs to a non-admin account.", settings.admin_username, + ) + raise + logger.info("Admin user already created by another worker") logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s", settings.llm_provider, settings.llm_model, settings.voice_mode) diff --git a/backend/app/routers/voice.py b/backend/app/routers/voice.py index 2a87bca..e49639e 100644 --- a/backend/app/routers/voice.py +++ b/backend/app/routers/voice.py @@ -7,7 +7,7 @@ from fastapi.responses import Response from app.auth import require_admin, get_current_user from app.config import get_settings from app.models import User -from app.schemas import VoiceConfigOut, VoiceInstructionRequest +from app.schemas import SynthesizeRequest, VoiceConfigOut, VoiceInstructionRequest from app.services.llm_service import summarize_instruction from app.services.voice_service import synthesize, transcribe @@ -48,12 +48,12 @@ async def transcribe_audio( @router.post("/synthesize") async def synthesize_text( - text: str, + body: SynthesizeRequest, user: User = Depends(get_current_user), ): """Convert text to speech audio (API mode only).""" try: - audio_bytes = await synthesize(text) + audio_bytes = await synthesize(body.text) return Response(content=audio_bytes, media_type="audio/mpeg") except Exception as e: logger.error(f"Synthesis failed: {str(e)}", exc_info=True) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0541a59..dbb2ee9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel): raw_text: str +class SynthesizeRequest(BaseModel): + text: str + + class VoiceConfigOut(BaseModel): voice_mode: str # "api" | "browser" voice_api_available: bool = False # True if API STT (Whisper) is configured diff --git a/backend/tests/test_startup.py b/backend/tests/test_startup.py new file mode 100644 index 0000000..f849ce8 --- /dev/null +++ b/backend/tests/test_startup.py @@ -0,0 +1,166 @@ +"""FluentGerman.ai — Startup / lifespan tests. + +The API tests drive the app through httpx's ASGITransport, which does NOT run +lifespan events — so table creation and the admin bootstrap in main.lifespan +were never exercised. The suite could stay green while the app failed to boot. +""" + +import asyncio + +import pytest +from sqlalchemy import func, inspect as sa_inspect, select +from sqlalchemy.exc import IntegrityError, OperationalError + +from app import main +from app.config import get_settings +from app.database import Base +from app.models import User +from tests.conftest import test_engine as engine, test_session as session_factory + + +@pytest.fixture +def startup_app(monkeypatch): + """Point main's module-level engine/session at the test database.""" + monkeypatch.setattr(main, "engine", engine) + monkeypatch.setattr(main, "async_session", session_factory) + return main.app + + +@pytest.mark.asyncio +async def test_lifespan_creates_schema_and_bootstraps_admin(startup_app): + """Starting from an empty database, startup must create tables and an admin.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + async with main.lifespan(startup_app): + pass + + async with session_factory() as db: + result = await db.execute(select(User).where(User.is_admin.is_(True))) + admin = result.scalar_one_or_none() + + assert admin is not None, "startup did not create an admin user" + assert admin.username == get_settings().admin_username + assert admin.hashed_password != get_settings().admin_password, "password stored in clear" + + +@pytest.mark.asyncio +async def test_lifespan_is_idempotent(startup_app): + """A restart must not create a second admin.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + for _ in range(2): + async with main.lifespan(startup_app): + pass + + async with session_factory() as db: + result = await db.execute( + select(func.count()).select_from(User).where(User.is_admin.is_(True)) + ) + assert result.scalar_one() == 1 + + +@pytest.mark.asyncio +async def test_lifespan_survives_concurrent_workers(startup_app): + """deploy/fluentgerman.service starts uvicorn with --workers 2, and each + worker runs this lifespan. On an empty database they race on the same + check-then-insert; neither worker may fail.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + async def boot(): + async with main.lifespan(startup_app): + pass + + await asyncio.gather(boot(), boot()) + + async with session_factory() as db: + result = await db.execute( + select(func.count()).select_from(User).where(User.is_admin.is_(True)) + ) + assert result.scalar_one() == 1, "concurrent startup created a duplicate admin" + + +@pytest.mark.asyncio +async def test_lifespan_reraises_real_ddl_failures(startup_app, monkeypatch): + """Only a genuine 'table already exists' race may be swallowed — a + permission or connection failure has to stop the worker.""" + def explode(*args, **kwargs): + raise OperationalError( + "CREATE TABLE users (...)", {}, + Exception("(1142, \"CREATE command denied to user 'fluentgerman'\")"), + ) + + monkeypatch.setattr(main.Base.metadata, "create_all", explode) + + with pytest.raises(OperationalError): + async with main.lifespan(startup_app): + pass + + +@pytest.mark.asyncio +async def test_lifespan_refuses_to_start_on_incomplete_schema(startup_app, monkeypatch): + """If some tables are missing, starting anyway would leave the app broken + in a way the admin query alone would not notice.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0) + monkeypatch.setattr(main.Base.metadata, "create_all", lambda *a, **k: None) + + with pytest.raises(RuntimeError, match="schema is incomplete"): + async with main.lifespan(startup_app): + pass + + +@pytest.mark.asyncio +async def test_lifespan_waits_out_a_partial_schema_race(startup_app, monkeypatch): + """An 'already exists' error aborts create_all part-way, so the losing + worker can briefly see a schema the winner is still finishing. That is a + moment to retry, not a reason to refuse to start.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + real_create_all = main.Base.metadata.create_all + calls = {"n": 0} + + def racing_create_all(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + # the winner got there first and we bailed out having created nothing + raise OperationalError( + "CREATE TABLE users (...)", {}, + Exception("table 'users' already exists"), + ) + return real_create_all(*args, **kwargs) + + monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0) + monkeypatch.setattr(main.Base.metadata, "create_all", racing_create_all) + + async with main.lifespan(startup_app): + pass + + assert calls["n"] >= 2, "should have retried after the race" + + async with engine.connect() as conn: + present = await conn.run_sync(lambda c: set(sa_inspect(c).get_table_names())) + assert set(Base.metadata.tables) <= present + + +@pytest.mark.asyncio +async def test_lifespan_fails_loudly_when_admin_name_is_taken(startup_app): + """A non-admin already holding the configured username is not a lost race — + starting with no administrator at all would be worse than refusing.""" + async with session_factory() as db: + db.add(User( + username=get_settings().admin_username, + email="someone-else@example.com", + hashed_password="x", + is_admin=False, + )) + await db.commit() + + with pytest.raises(IntegrityError): + async with main.lifespan(startup_app): + pass diff --git a/deploy/fluentgerman.service b/deploy/fluentgerman.service index 74048e8..5d3e162 100644 --- a/deploy/fluentgerman.service +++ b/deploy/fluentgerman.service @@ -1,6 +1,6 @@ [Unit] Description=FluentGerman.ai — Personalized LLM Language Learning -After=network.target postgresql.service +After=network.target mysql.service [Service] Type=simple diff --git a/deploy/setup.sh b/deploy/setup.sh index b28654c..77207dc 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -6,6 +6,14 @@ set -e APP_NAME="fluentgerman" APP_DIR="/opt/$APP_NAME" +# Resolved from this script's location, so the copies below survive the `cd` +# in step 5 and don't depend on where the script was invoked from. +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# A fresh install has no .env yet, so nothing depends on the database password +# and we can generate one. An existing install keeps whatever its .env holds. +FRESH_INSTALL=true +[ -f "$APP_DIR/backend/.env" ] && FRESH_INSTALL=false APP_USER="fluentgerman" DB_NAME="fluentgerman" DB_USER="fluentgerman" @@ -23,18 +31,27 @@ apt-get update -qq apt-get install -y -qq python3 python3-venv python3-pip > /dev/null echo "✓ Python installed" +# Generated here, not earlier: a minimal Debian has no python3 until now +DB_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" + # 3. MySQL database setup echo "Setting up MySQL database..." mysql -u root -e "CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -mysql -u root -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY 'CHANGE_ME';" +mysql -u root -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';" +if [ "$FRESH_INSTALL" = true ]; then + # Nothing is using the old password yet — pin it to the one .env will get + mysql -u root -e "ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';" +fi mysql -u root -e "GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';" mysql -u root -e "FLUSH PRIVILEGES;" echo "✓ MySQL database ready" # 4. Application directory -mkdir -p "$APP_DIR" -cp -r backend/* "$APP_DIR/backend/" -cp -r frontend/* "$APP_DIR/frontend/" +mkdir -p "$APP_DIR/backend" "$APP_DIR/frontend" +cp -r "$REPO_DIR"/backend/* "$APP_DIR/backend/" +# `*` never matches dotfiles, and step 6 needs this one +cp "$REPO_DIR/backend/.env.example" "$APP_DIR/backend/" +cp -r "$REPO_DIR"/frontend/* "$APP_DIR/frontend/" chown -R "$APP_USER:$APP_USER" "$APP_DIR" echo "✓ Files deployed to $APP_DIR" @@ -47,29 +64,46 @@ deactivate echo "✓ Python venv created" # 6. Environment file -if [ ! -f "$APP_DIR/backend/.env" ]; then +if [ "$FRESH_INSTALL" = true ]; then cp "$APP_DIR/backend/.env.example" "$APP_DIR/backend/.env" # Generate random secret key SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))") sed -i "s/generate-a-strong-random-key-here/$SECRET/" "$APP_DIR/backend/.env" + # Match the database user created above (token_urlsafe is / and # free) + sed -i "s#://$DB_USER:YOUR_PASSWORD@#://$DB_USER:$DB_PASSWORD@#" "$APP_DIR/backend/.env" + # It holds real credentials now + chown "$APP_USER:$APP_USER" "$APP_DIR/backend/.env" + chmod 600 "$APP_DIR/backend/.env" echo "⚠ Created .env from template — EDIT $APP_DIR/backend/.env with your API keys and passwords!" fi # 7. Systemd service -cp deploy/fluentgerman.service /etc/systemd/system/ +cp "$REPO_DIR/deploy/fluentgerman.service" /etc/systemd/system/ systemctl daemon-reload systemctl enable "$APP_NAME" -systemctl start "$APP_NAME" -echo "✓ Systemd service active" +if [ "$FRESH_INSTALL" = true ]; then + # Don't go live with the template's LLM key and default admin password + echo "✓ Systemd service installed (not started — configure .env first)" +else + systemctl restart "$APP_NAME" + echo "✓ Systemd service restarted" +fi # 8. Nginx config -cp deploy/nginx.conf.example /etc/nginx/sites-available/$APP_NAME +cp "$REPO_DIR/deploy/nginx.conf.example" /etc/nginx/sites-available/$APP_NAME ln -sf /etc/nginx/sites-available/$APP_NAME /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx echo "✓ Nginx configured" echo "" echo "=== Deployment complete! ===" -echo "1. Edit /opt/$APP_NAME/backend/.env with your settings" -echo "2. Restart: systemctl restart $APP_NAME" -echo "3. Access: http://your-server-domain" +if [ "$FRESH_INSTALL" = true ]; then + echo "The database password was generated and written to .env — leave it alone." + echo "1. Edit /opt/$APP_NAME/backend/.env: set LLM_API_KEY and ADMIN_PASSWORD" + echo "2. Start it: systemctl start $APP_NAME" + echo "3. Access: http://your-server-domain" +else + echo "1. Edit /opt/$APP_NAME/backend/.env if anything changed" + echo "2. Restart: systemctl restart $APP_NAME" + echo "3. Access: http://your-server-domain" +fi diff --git a/frontend/admin.html b/frontend/admin.html index d3ad03d..7cd7240 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -54,8 +54,10 @@ - - - + + + \ No newline at end of file diff --git a/frontend/chat.html b/frontend/chat.html index e917187..5c81cbd 100644 --- a/frontend/chat.html +++ b/frontend/chat.html @@ -7,8 +7,10 @@ FluentGerman.ai — Chat - - + + + @@ -17,6 +19,7 @@ @@ -70,9 +73,9 @@ - - - + + + \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index 3fead82..9e3314f 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -398,6 +398,8 @@ textarea { display: flex; flex-direction: column; height: calc(100vh - 57px); + /* dvh follows the mobile toolbar as it collapses; vh above is the fallback */ + height: calc(100dvh - 57px); } .chat-messages { @@ -1040,6 +1042,13 @@ tr:hover td { padding: 12px 16px; } + /* iOS Safari zooms the whole page in when a focused field is under 16px */ + input, + select, + textarea { + font-size: 16px; + } + .message { max-width: 88%; } diff --git a/frontend/index.html b/frontend/index.html index 6be369f..e8fc0da 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -30,11 +30,11 @@ - v0.3.1 + v0.3.3 - - + + \ No newline at end of file diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 092903b..435cefb 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -6,6 +6,16 @@ document.addEventListener('DOMContentLoaded', () => { const user = getUser(); document.getElementById('admin-name').textContent = user?.username || 'Admin'; + /** Escape text destined for innerHTML or an HTML attribute. */ + function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); + } + + // id → username, so instructions can show who they belong to + let usersById = new Map(); + // Tab switching const tabs = document.querySelectorAll('.tab'); const panels = document.querySelectorAll('.tab-panel'); @@ -16,6 +26,11 @@ document.addEventListener('DOMContentLoaded', () => { panels.forEach(p => p.classList.add('hidden')); tab.classList.add('active'); document.getElementById(tab.dataset.panel).classList.remove('hidden'); + + // Clicking the tab itself clears any per-client filter set via 📝 + if (tab.dataset.panel === 'instructions-panel' && currentFilterUserId !== null) { + clearInstructionFilter(); + } }); }); @@ -28,6 +43,7 @@ document.addEventListener('DOMContentLoaded', () => { async function loadUsers() { try { const users = await apiJSON('/users/'); + usersById = new Map(users.map(u => [u.id, u.username])); usersBody.innerHTML = ''; if (users.length === 0) { @@ -38,13 +54,14 @@ document.addEventListener('DOMContentLoaded', () => { users.forEach(u => { const row = document.createElement('tr'); row.innerHTML = ` - ${u.username} - ${u.email} + ${escapeHtml(u.username)} + ${escapeHtml(u.email)} ${u.is_active ? 'Active' : 'Inactive'} ${new Date(u.created_at).toLocaleDateString()} - + `; @@ -120,7 +137,7 @@ document.addEventListener('DOMContentLoaded', () => { window.deleteUser = async (id) => { if (!confirm('Delete this client? This will also remove all their instructions.')) return; try { - await api(`/users/${id}`, { method: 'DELETE' }); + await apiVoid(`/users/${id}`, { method: 'DELETE' }); showToast('Client deleted'); loadUsers(); } catch (e) { @@ -150,10 +167,10 @@ document.addEventListener('DOMContentLoaded', () => { instructions.forEach(inst => { const row = document.createElement('tr'); row.innerHTML = ` - ${inst.title} + ${escapeHtml(inst.title)} ${inst.type} - ${inst.user_id || 'Global'} - ${inst.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''} + ${inst.user_id ? escapeHtml(usersById.get(inst.user_id) || `#${inst.user_id}`) : 'Global'} + ${escapeHtml(inst.content.substring(0, 60))}${inst.content.length > 60 ? '...' : ''} @@ -171,21 +188,39 @@ document.addEventListener('DOMContentLoaded', () => { const users = await apiJSON('/users/'); instrUserSelect.innerHTML = ''; users.forEach(u => { - instrUserSelect.innerHTML += ``; + instrUserSelect.innerHTML += ``; }); } catch (e) { // silently fail } } - window.manageInstructions = (userId, username) => { + function applyInstructionFilter(userId, username) { currentFilterUserId = userId; + + const heading = document.getElementById('instructions-heading'); + const clearBtn = document.getElementById('instr-filter-clear'); + + if (userId === null) { + heading.textContent = 'Instructions'; + clearBtn.classList.add('hidden'); + } else { + heading.textContent = `Instructions — ${username}`; + clearBtn.classList.remove('hidden'); + } + + loadInstructions(userId); + } + + window.clearInstructionFilter = () => applyInstructionFilter(null, null); + + window.manageInstructions = (userId, username) => { // Switch to instructions tab tabs.forEach(t => t.classList.remove('active')); panels.forEach(p => p.classList.add('hidden')); document.querySelector('[data-panel="instructions-panel"]').classList.add('active'); document.getElementById('instructions-panel').classList.remove('hidden'); - loadInstructions(userId); + applyInstructionFilter(userId, username); }; window.showInstructionModal = () => { @@ -253,7 +288,7 @@ document.addEventListener('DOMContentLoaded', () => { window.deleteInstruction = async (id) => { if (!confirm('Delete this instruction?')) return; try { - await api(`/instructions/${id}`, { method: 'DELETE' }); + await apiVoid(`/instructions/${id}`, { method: 'DELETE' }); showToast('Instruction deleted'); loadInstructions(currentFilterUserId); } catch (e) { @@ -271,9 +306,11 @@ document.addEventListener('DOMContentLoaded', () => { if (!file) return; const text = await file.text(); + // Open the modal first: showInstructionModal() calls form.reset(), + // which would wipe anything prefilled before it. + showInstructionModal(); document.getElementById('instr-title').value = file.name.replace(/\.[^.]+$/, ''); document.getElementById('instr-content').value = text; - showInstructionModal(); }; input.click(); }; @@ -328,14 +365,14 @@ document.addEventListener('DOMContentLoaded', () => { const content = voiceGenText.value.trim(); if (!content) return; - loadUserOptions(); + // Open the modal first: showInstructionModal() calls form.reset() and + // loadUserOptions() itself, and would wipe anything prefilled before it. + showInstructionModal(); document.getElementById('instr-content').value = content; document.getElementById('instr-title').value = 'Voice Generated Instruction'; - showInstructionModal(); }; // ── Init ─────────────────────────────────────────────────────── - loadUsers(); - loadInstructions(); + loadUsers().then(() => loadInstructions()); document.getElementById('logout-btn').addEventListener('click', logout); }); diff --git a/frontend/js/api.js b/frontend/js/api.js index d9180a1..6b69948 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -52,15 +52,27 @@ async function api(path, options = {}) { return response; } -async function apiJSON(path, options = {}) { - const response = await api(path, options); - if (!response || !response.ok) { - const error = await response?.json().catch(() => ({ detail: 'Request failed' })); - throw new Error(error.detail || 'Request failed'); +async function ensureOk(response) { + // api() returns undefined after a 401 — the redirect is already under way + if (!response) throw new Error('Session expired'); + + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(error?.detail || 'Request failed'); } + return response; +} + +async function apiJSON(path, options = {}) { + const response = await ensureOk(await api(path, options)); return response.json(); } +/* Same, for endpoints that answer 204 No Content (the deletes). */ +async function apiVoid(path, options = {}) { + await ensureOk(await api(path, options)); +} + function requireAuth() { if (!getToken()) { window.location.href = '/'; @@ -81,6 +93,8 @@ function requireAdmin() { function logout() { clearToken(); clearUser(); + // Drop the saved conversation as well — shared devices are the norm here + try { sessionStorage.clear(); } catch (e) { /* storage may be blocked */ } window.location.href = '/'; } diff --git a/frontend/js/chat.js b/frontend/js/chat.js index c534a4e..2ef91c0 100644 --- a/frontend/js/chat.js +++ b/frontend/js/chat.js @@ -58,6 +58,15 @@ document.addEventListener('DOMContentLoaded', async () => { let history = []; let voiceModeOn = false; + let sending = false; + let abortController = null; + // Bumped by "New chat" — a send started before the bump must not write + // its late results into the conversation that replaced it. + let chatGeneration = 0; + + // Conversations survive a reload but not a closed tab, and are scoped to + // the logged-in user so a shared device never shows someone else's lesson. + const CHAT_KEY = `fg_chat_${user?.id ?? 'anon'}`; // ── Personalised welcome ────────────────────────────────────────── const greetingEl = document.getElementById('welcome-greeting'); @@ -156,14 +165,123 @@ document.addEventListener('DOMContentLoaded', async () => { return div; } + /** + * Read an SSE stream to completion, calling onToken for every token. + * + * Buffers across reads: a chunk boundary can fall anywhere, splitting a + * multi-byte character (ä, ö, ü, ß) or a whole event across two reads. + */ + function saveHistory() { + try { + sessionStorage.setItem(CHAT_KEY, JSON.stringify(history.slice(-40))); + } catch (e) { + console.warn('[Chat] Could not save conversation:', e); + } + } + + function restoreHistory() { + let saved = []; + try { + saved = JSON.parse(sessionStorage.getItem(CHAT_KEY) || '[]'); + } catch (e) { + return; + } + if (!Array.isArray(saved) || saved.length === 0) return; + + history = saved; + for (const msg of saved) { + if (msg?.role && typeof msg.content === 'string') { + appendMessage(msg.role, msg.content); + } + } + } + + function startNewChat() { + chatGeneration++; + abortController?.abort(); + voice.stopPlayback(); + abortController = null; + setSending(false); + history = []; + try { + sessionStorage.removeItem(CHAT_KEY); + } catch (e) { + /* storage may be blocked */ + } + messagesEl.querySelectorAll('.message').forEach(el => el.remove()); + inputEl.focus(); + } + + /** Toggle the composer between "Send" and "Stop". */ + function setSending(on) { + sending = on; + sendBtn.textContent = on ? 'Stop' : 'Send'; + sendBtn.classList.toggle('btn-danger', on); + sendBtn.classList.toggle('btn-primary', !on); + } + + async function readSSE(response, onToken) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finished = false; + + const handleEvent = (event) => { + for (const line of event.split('\n')) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '[DONE]') { + finished = true; + return; + } + try { + const parsed = JSON.parse(data); + if (parsed.token) onToken(parsed.token); + if (parsed.error) showToast(parsed.error, 'error'); + } catch (err) { + console.warn('[Chat] Unparseable SSE data:', data); + } + } + }; + + while (!finished) { + const { done, value } = await reader.read(); + + if (done) { + buffer += decoder.decode(); // flush any pending bytes + if (buffer.trim()) handleEvent(buffer); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Events are separated by a blank line — keep the trailing fragment. + const events = buffer.split('\n\n'); + buffer = events.pop(); + + for (const event of events) { + handleEvent(event); + if (finished) break; + } + } + + try { await reader.cancel(); } catch (err) { /* already closed */ } + } + async function sendMessage() { + if (sending) return; + const text = inputEl.value.trim(); if (!text) return; + setSending(true); + const controller = new AbortController(); + abortController = controller; + const generation = chatGeneration; + const isCurrent = () => generation === chatGeneration; voice.lastInputWasVoice = false; inputEl.value = ''; - sendBtn.disabled = true; appendMessage('user', text); history.push({ role: 'user', content: text }); @@ -175,6 +293,7 @@ document.addEventListener('DOMContentLoaded', async () => { const response = await api('/chat/', { method: 'POST', body: JSON.stringify({ message: text, history: history.slice(-20) }), + signal: controller.signal, }); if (!response?.ok) { @@ -182,94 +301,96 @@ document.addEventListener('DOMContentLoaded', async () => { throw new Error(errData.detail || `Chat failed (${response?.status})`); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - // Special handling for Voice Mode: Buffer text, wait for TTS, then show & play if (voiceModeOn) { - // "Thinking..." is already shown from appendMessage above + // Buffer the whole reply, fetch its audio, then reveal text and + // player together — "Thinking..." stays up until audio is ready. + await readSSE(response, (token) => { + fullResponse += token; + }); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data === '[DONE]') break; - try { - const parsed = JSON.parse(data); - if (parsed.token) fullResponse += parsed.token; - if (parsed.error) showToast(parsed.error, 'error'); - } catch (e) { } - } - } - } + if (!isCurrent()) return; + assistantEl.classList.remove('message-thinking'); - // Text complete. Now fetch audio. if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); + saveHistory(); - // Keep "Thinking..." until audio is ready or failed - const audioUrl = await voice.fetchAudio(fullResponse); + // Same signal as the chat request, so Stop cancels TTS too + const audioUrl = await voice.fetchAudio(fullResponse, controller.signal); + if (!isCurrent()) return; - // Visual update: Remove thinking, show text - assistantEl.classList.remove('message-thinking'); + // The reply itself is complete — show it even when Stop + // cancelled the audio assistantEl.innerHTML = renderMarkdown(fullResponse); messagesEl.scrollTop = messagesEl.scrollHeight; - if (audioUrl) { + if (audioUrl && !controller.signal.aborted) { + // Re-enable sending before playback: playAudio() only + // settles on ended/error, so a paused player would + // otherwise keep chat locked until a reload. + setSending(false); await voice.playAudio(audioUrl, assistantEl); } + } else { + assistantEl.textContent = 'No response received. Please try again.'; } - } else { - // Normal Text Mode: Stream directly to UI - while (true) { - const { done, value } = await reader.read(); - if (done) break; + // Text mode: stream straight into the bubble + await readSSE(response, (token) => { + fullResponse += token; + assistantEl.innerHTML = renderMarkdown(fullResponse); + messagesEl.scrollTop = messagesEl.scrollHeight; + }); - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data === '[DONE]') break; - - try { - const parsed = JSON.parse(data); - if (parsed.token) { - fullResponse += parsed.token; - assistantEl.innerHTML = renderMarkdown(fullResponse); - messagesEl.scrollTop = messagesEl.scrollHeight; - } - if (parsed.error) { - showToast(parsed.error, 'error'); - } - } catch (e) { - // skip unparseable chunks - } - } - } - } + if (!isCurrent()) return; if (fullResponse) { history.push({ role: 'assistant', content: fullResponse }); + saveHistory(); + } else { + assistantEl.textContent = 'No response received. Please try again.'; } } } catch (e) { - assistantEl.textContent = 'Sorry, something went wrong. Please try again.'; - showToast(e.message, 'error'); - console.error('[Chat] Error:', e); + if (e.name === 'AbortError') { + // Aborted by "New chat": that conversation is gone, discard this + if (!isCurrent()) { + assistantEl.remove(); + return; + } + // User pressed Stop — keep the partial reply, drop an empty one + assistantEl.classList.remove('message-thinking'); + if (fullResponse) { + assistantEl.innerHTML = renderMarkdown(fullResponse); + history.push({ role: 'assistant', content: fullResponse }); + saveHistory(); + } else { + assistantEl.remove(); + } + } else { + assistantEl.textContent = 'Sorry, something went wrong. Please try again.'; + showToast(e.message, 'error'); + console.error('[Chat] Error:', e); + } + } finally { + // In voice mode this runs after playback, by which time a newer + // send may already own the composer — don't clobber its state. + if (abortController === controller) { + abortController = null; + setSending(false); + } + if (isCurrent()) saveHistory(); + inputEl.focus(); } - - sendBtn.disabled = false; - inputEl.focus(); } - sendBtn.addEventListener('click', sendMessage); + sendBtn.addEventListener('click', () => { + if (sending) { + abortController?.abort(); + return; + } + sendMessage(); + }); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -277,6 +398,11 @@ document.addEventListener('DOMContentLoaded', async () => { } }); + document.getElementById('new-chat-btn')?.addEventListener('click', startNewChat); + // Logout document.getElementById('logout-btn').addEventListener('click', logout); + + // Bring back this session's conversation, if there is one + restoreHistory(); }); diff --git a/frontend/js/voice.js b/frontend/js/voice.js index ab31b47..2dcf316 100644 --- a/frontend/js/voice.js +++ b/frontend/js/voice.js @@ -14,6 +14,9 @@ class VoiceManager { this.browserSTTSupported = false; this.apiAvailable = false; this.onProcessing = null; // New callback for "Transcribing..." state + this.currentAudio = null; // clip currently playing, if any + this._resolvePlayback = null; // settles whoever awaits playAudio() + this._resetPlayerUI = null; // puts that clip's player back to rest } async init() { @@ -98,10 +101,18 @@ class VoiceManager { // if hardware access fails or takes time. if (this.mode === 'api') { + if (typeof MediaRecorder === 'undefined') { + showToast('This browser cannot record audio.', 'error'); + return; + } + try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this.audioChunks = []; - this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); + + // Safari/iOS has no webm — let it fall through to mp4 + const mimeType = VoiceManager.pickMimeType(); + this.mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.audioChunks.push(e.data); @@ -109,7 +120,11 @@ class VoiceManager { this.mediaRecorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); - const blob = new Blob(this.audioChunks, { type: 'audio/webm' }); + // Use what the recorder produced, not what we asked for. + // A few old WebViews report an empty mimeType even though + // they recorded something else — webm is the best guess left. + const type = this.mediaRecorder.mimeType || mimeType || 'audio/webm'; + const blob = new Blob(this.audioChunks, { type }); await this._transcribeAPI(blob); }; @@ -124,7 +139,12 @@ class VoiceManager { } catch (e) { console.error('[Voice] Mic access error:', e); - showToast('Microphone access denied or error', 'error'); + showToast( + e.name === 'NotAllowedError' + ? 'Microphone access denied. Allow it in browser settings.' + : `Could not start recording: ${e.message || e.name}`, + 'error' + ); this.isRecording = false; if (this.onStateChange) this.onStateChange(false); } @@ -175,7 +195,9 @@ class VoiceManager { try { const formData = new FormData(); - formData.append('audio', blob, 'recording.webm'); + // The OpenAI SDK infers the audio format from this filename, so it + // has to match what the recorder actually produced. + formData.append('audio', blob, `recording.${VoiceManager.extensionFor(blob.type)}`); const response = await api('/voice/transcribe', { method: 'POST', @@ -204,13 +226,15 @@ class VoiceManager { * Fetch TTS audio blob for text (API only). * Returns audio URL or null. */ - async fetchAudio(text) { + async fetchAudio(text, signal) { if (!this.apiAvailable) return null; const clean = VoiceManager.stripMarkdown(text); try { - const response = await api(`/voice/synthesize?text=${encodeURIComponent(clean)}`, { + const response = await api('/voice/synthesize', { method: 'POST', + body: JSON.stringify({ text: clean }), + signal, }); if (response?.ok) { @@ -235,7 +259,13 @@ class VoiceManager { async playAudio(audioUrl, containerEl) { if (!audioUrl) return; + this.stopPlayback(); // only one clip at a time + const audio = new Audio(audioUrl); + this.currentAudio = audio; + + // Settles on end, on error, or when stopPlayback() is called + const finished = new Promise(resolve => { this._resolvePlayback = resolve; }); // Visual feedback — avatar pulse const avatarContainer = document.querySelector('.avatar-container'); @@ -268,6 +298,15 @@ class VoiceManager { containerEl.appendChild(player); } + // Lets stopPlayback() return this player to a resting state + const resetPlayerUI = () => { + playBtn.classList.remove('playing'); + playBtn.innerHTML = VoiceManager._playIcon(); + playBtn.title = 'Replay'; + fill.style.width = '0%'; + }; + this._resetPlayerUI = resetPlayerUI; + // ── Helpers ─────────────────────────────────────────────────── function fmt(s) { if (!isFinite(s)) return '0:00'; @@ -301,6 +340,10 @@ class VoiceManager { // Play/pause toggle playBtn.addEventListener('click', () => { if (audio.paused) { + // Register the replay, otherwise stopPlayback() can't reach it + this.stopPlayback(); + this.currentAudio = audio; + this._resetPlayerUI = resetPlayerUI; audio.play(); playBtn.classList.add('playing'); playBtn.innerHTML = VoiceManager._pauseIcon(); @@ -308,6 +351,10 @@ class VoiceManager { if (avatarContainer) avatarContainer.classList.add('speaking'); } else { audio.pause(); + if (this.currentAudio === audio) { + this.currentAudio = null; + this._resetPlayerUI = null; + } playBtn.classList.remove('playing'); playBtn.innerHTML = VoiceManager._playIcon(); playBtn.title = 'Play'; @@ -317,38 +364,78 @@ class VoiceManager { // ── Playback ────────────────────────────────────────────────── try { - // Wait for audio to be fully buffered before playing - await new Promise((resolve, reject) => { - audio.addEventListener('canplaythrough', resolve, { once: true }); - audio.addEventListener('error', reject, { once: true }); - audio.load(); // Explicitly trigger loading - }); + // Wait for audio to be fully buffered before playing — but give up + // if stopPlayback() cuts in while it is still loading + await Promise.race([ + new Promise((resolve, reject) => { + audio.addEventListener('canplaythrough', resolve, { once: true }); + audio.addEventListener('error', reject, { once: true }); + audio.load(); // Explicitly trigger loading + }), + finished, + ]); + + if (this.currentAudio !== audio) return; // stopped while loading + audio.currentTime = 0; // Ensure we start from the very beginning await audio.play(); - return new Promise(resolve => { - audio.onended = () => { - if (avatarContainer) avatarContainer.classList.remove('speaking'); - playBtn.classList.remove('playing'); - playBtn.innerHTML = VoiceManager._playIcon(); - playBtn.title = 'Replay'; - fill.style.width = '100%'; - // Reset to beginning for replay - audio.currentTime = 0; - resolve(); - }; - audio.onerror = () => { - if (avatarContainer) avatarContainer.classList.remove('speaking'); - resolve(); - }; - }); + + audio.onended = () => { + if (avatarContainer) avatarContainer.classList.remove('speaking'); + playBtn.classList.remove('playing'); + playBtn.innerHTML = VoiceManager._playIcon(); + playBtn.title = 'Replay'; + fill.style.width = '100%'; + // Reset to beginning for replay + audio.currentTime = 0; + this._settlePlayback(); + }; + audio.onerror = () => { + if (avatarContainer) avatarContainer.classList.remove('speaking'); + this._settlePlayback(); + }; + return finished; } catch (e) { console.error('Playback failed', e); if (avatarContainer) avatarContainer.classList.remove('speaking'); playBtn.classList.remove('playing'); playBtn.innerHTML = VoiceManager._playIcon(); + this._settlePlayback(); } } + /** + * Stop whatever is playing right now. Without this the Audio object is + * unreachable once playAudio() returns, so removing the player from the + * DOM leaves a detached clip still talking. + */ + stopPlayback() { + const audio = this.currentAudio; + if (audio) { + audio.pause(); + try { audio.currentTime = 0; } catch (e) { /* not seekable yet */ } + } + + // Leave onended attached so a later replay still resets its own player + const resetUI = this._resetPlayerUI; + this._resetPlayerUI = null; + if (resetUI) resetUI(); + + const avatarContainer = document.querySelector('.avatar-container'); + if (avatarContainer) avatarContainer.classList.remove('speaking'); + + this._settlePlayback(); + } + + /** Release anyone awaiting playAudio() and forget the current clip. */ + _settlePlayback() { + this.currentAudio = null; + this._resetPlayerUI = null; + const resolve = this._resolvePlayback; + this._resolvePlayback = null; + if (resolve) resolve(); + } + // ── SVG icons (inline, no external deps) ────────────────────────── static _playIcon() { return ``; @@ -367,6 +454,31 @@ class VoiceManager { if (url) await this.playAudio(url); } + /** + * Pick a recording format this browser supports. + * Chrome/Edge/Firefox give webm; Safari and iOS only do mp4. + * Returns '' to let the browser choose its own default. + */ + static pickMimeType() { + const candidates = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4', + 'audio/ogg;codecs=opus', + ]; + + if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) return ''; + return candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''; + } + + /** File extension matching a recorded blob's MIME type. */ + static extensionFor(mimeType = '') { + if (mimeType.includes('mp4')) return 'mp4'; + if (mimeType.includes('mpeg')) return 'mp3'; + if (mimeType.includes('ogg')) return 'ogg'; + return 'webm'; + } + /** * Strip markdown formatting from text so TTS reads naturally. */