Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s

This commit is contained in:
2026-08-29 17:28:50 +02:00
parent e9f12bc2ba
commit d974aaedc8
15 changed files with 746 additions and 153 deletions
+19 -5
View File
@@ -19,7 +19,7 @@ A web-based tool for a German language teacher to provide clients with customize
| Layer | Technology | | Layer | Technology |
|-------|-----------| |-------|-----------|
| Backend | Python 3.12 + FastAPI | | Backend | Python 3.12 + FastAPI |
| Database | PostgreSQL (async via SQLAlchemy + asyncpg) | | Database | MySQL (async via SQLAlchemy + aiomysql) |
| Auth | JWT + bcrypt | | Auth | JWT + bcrypt |
| LLM | LiteLLM (provider-agnostic) | | LLM | LiteLLM (provider-agnostic) |
| Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) | | Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) |
@@ -47,12 +47,26 @@ uvicorn app.main:app --reload
## Production Deployment (Debian) ## Production Deployment (Debian)
```bash ```bash
# Prerequisites: PostgreSQL and nginx already installed # Prerequisites: MySQL and nginx already installed
sudo bash deploy/setup.sh 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 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: # Restart after config changes:
sudo systemctl restart fluentgerman sudo systemctl restart fluentgerman
``` ```
@@ -64,7 +78,7 @@ sudo systemctl restart fluentgerman
│ ├── app/ │ ├── app/
│ │ ├── main.py # FastAPI entry point │ │ ├── main.py # FastAPI entry point
│ │ ├── config.py # Environment-based settings │ │ ├── config.py # Environment-based settings
│ │ ├── database.py # Async PostgreSQL setup │ │ ├── database.py # Async MySQL setup
│ │ ├── models.py # User & Instruction models │ │ ├── models.py # User & Instruction models
│ │ ├── schemas.py # Pydantic request/response │ │ ├── schemas.py # Pydantic request/response
│ │ ├── auth.py # JWT + bcrypt + dependencies │ │ ├── auth.py # JWT + bcrypt + dependencies
@@ -97,7 +111,7 @@ All settings via `.env` file (see `.env.example`):
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `SECRET_KEY` | JWT signing key (generate a strong random one) | | `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_API_KEY` | Your LLM provider API key |
| `LLM_MODEL` | Model to use (e.g. `gpt-4o-mini`, `claude-3-haiku-20240307`) | | `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) | | `VOICE_MODE` | `api` (OpenAI Whisper/TTS) or `browser` (Web Speech API) |
+76 -4
View File
@@ -1,5 +1,6 @@
"""FluentGerman.ai — FastAPI application entry point.""" """FluentGerman.ai — FastAPI application entry point."""
import asyncio
import logging import logging
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -7,7 +8,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles 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.auth import hash_password
from app.config import get_settings from app.config import get_settings
@@ -30,11 +32,68 @@ logging.basicConfig(
logger = logging.getLogger("fluentgerman") logger = logging.getLogger("fluentgerman")
@asynccontextmanager # A worker that loses the create_all race has to wait for the winner to finish
async def lifespan(app: FastAPI): # creating the remaining tables before it can judge the schema complete.
"""Create tables and bootstrap admin user on startup.""" 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: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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.
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 # Bootstrap admin if not exists
settings = get_settings() settings = get_settings()
@@ -48,8 +107,21 @@ async def lifespan(app: FastAPI):
is_admin=True, is_admin=True,
) )
db.add(admin) db.add(admin)
try:
await db.commit() await db.commit()
logger.info("Admin user created: %s", settings.admin_username) 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", logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
settings.llm_provider, settings.llm_model, settings.voice_mode) settings.llm_provider, settings.llm_model, settings.voice_mode)
+3 -3
View File
@@ -7,7 +7,7 @@ from fastapi.responses import Response
from app.auth import require_admin, get_current_user from app.auth import require_admin, get_current_user
from app.config import get_settings from app.config import get_settings
from app.models import User 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.llm_service import summarize_instruction
from app.services.voice_service import synthesize, transcribe from app.services.voice_service import synthesize, transcribe
@@ -48,12 +48,12 @@ async def transcribe_audio(
@router.post("/synthesize") @router.post("/synthesize")
async def synthesize_text( async def synthesize_text(
text: str, body: SynthesizeRequest,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
"""Convert text to speech audio (API mode only).""" """Convert text to speech audio (API mode only)."""
try: try:
audio_bytes = await synthesize(text) audio_bytes = await synthesize(body.text)
return Response(content=audio_bytes, media_type="audio/mpeg") return Response(content=audio_bytes, media_type="audio/mpeg")
except Exception as e: except Exception as e:
logger.error(f"Synthesis failed: {str(e)}", exc_info=True) logger.error(f"Synthesis failed: {str(e)}", exc_info=True)
+4
View File
@@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel):
raw_text: str raw_text: str
class SynthesizeRequest(BaseModel):
text: str
class VoiceConfigOut(BaseModel): class VoiceConfigOut(BaseModel):
voice_mode: str # "api" | "browser" voice_mode: str # "api" | "browser"
voice_api_available: bool = False # True if API STT (Whisper) is configured voice_api_available: bool = False # True if API STT (Whisper) is configured
+166
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=FluentGerman.ai — Personalized LLM Language Learning Description=FluentGerman.ai — Personalized LLM Language Learning
After=network.target postgresql.service After=network.target mysql.service
[Service] [Service]
Type=simple Type=simple
+44 -10
View File
@@ -6,6 +6,14 @@ set -e
APP_NAME="fluentgerman" APP_NAME="fluentgerman"
APP_DIR="/opt/$APP_NAME" 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" APP_USER="fluentgerman"
DB_NAME="fluentgerman" DB_NAME="fluentgerman"
DB_USER="fluentgerman" DB_USER="fluentgerman"
@@ -23,18 +31,27 @@ apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip > /dev/null apt-get install -y -qq python3 python3-venv python3-pip > /dev/null
echo "✓ Python installed" 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 # 3. MySQL database setup
echo "Setting up MySQL database..." 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 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 "GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';"
mysql -u root -e "FLUSH PRIVILEGES;" mysql -u root -e "FLUSH PRIVILEGES;"
echo "✓ MySQL database ready" echo "✓ MySQL database ready"
# 4. Application directory # 4. Application directory
mkdir -p "$APP_DIR" mkdir -p "$APP_DIR/backend" "$APP_DIR/frontend"
cp -r backend/* "$APP_DIR/backend/" cp -r "$REPO_DIR"/backend/* "$APP_DIR/backend/"
cp -r frontend/* "$APP_DIR/frontend/" # `*` 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" chown -R "$APP_USER:$APP_USER" "$APP_DIR"
echo "✓ Files deployed to $APP_DIR" echo "✓ Files deployed to $APP_DIR"
@@ -47,29 +64,46 @@ deactivate
echo "✓ Python venv created" echo "✓ Python venv created"
# 6. Environment file # 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" cp "$APP_DIR/backend/.env.example" "$APP_DIR/backend/.env"
# Generate random secret key # Generate random secret key
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))") SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))")
sed -i "s/generate-a-strong-random-key-here/$SECRET/" "$APP_DIR/backend/.env" 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!" echo "⚠ Created .env from template — EDIT $APP_DIR/backend/.env with your API keys and passwords!"
fi fi
# 7. Systemd service # 7. Systemd service
cp deploy/fluentgerman.service /etc/systemd/system/ cp "$REPO_DIR/deploy/fluentgerman.service" /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$APP_NAME" systemctl enable "$APP_NAME"
systemctl start "$APP_NAME" if [ "$FRESH_INSTALL" = true ]; then
echo "✓ Systemd service active" # 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 # 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/ ln -sf /etc/nginx/sites-available/$APP_NAME /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
echo "✓ Nginx configured" echo "✓ Nginx configured"
echo "" echo ""
echo "=== Deployment complete! ===" echo "=== Deployment complete! ==="
echo "1. Edit /opt/$APP_NAME/backend/.env with your settings" 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 "2. Restart: systemctl restart $APP_NAME"
echo "3. Access: http://your-server-domain" echo "3. Access: http://your-server-domain"
fi
+6 -4
View File
@@ -54,8 +54,10 @@
<!-- Instructions Panel --> <!-- Instructions Panel -->
<div id="instructions-panel" class="tab-panel hidden"> <div id="instructions-panel" class="tab-panel hidden">
<div class="flex-between mb-16"> <div class="flex-between mb-16">
<h2>Instructions</h2> <h2 id="instructions-heading">Instructions</h2>
<div class="flex gap-8"> <div class="flex gap-8">
<button class="btn btn-secondary btn-sm hidden" id="instr-filter-clear"
onclick="clearInstructionFilter()">✕ Show all</button>
<button class="btn btn-secondary btn-sm" onclick="uploadInstructionFile()">📁 Upload File</button> <button class="btn btn-secondary btn-sm" onclick="uploadInstructionFile()">📁 Upload File</button>
<button class="btn btn-primary btn-sm" onclick="showInstructionModal()">+ Add</button> <button class="btn btn-primary btn-sm" onclick="showInstructionModal()">+ Add</button>
</div> </div>
@@ -174,9 +176,9 @@
</div> </div>
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js?v=0.3.3"></script>
<script src="/js/voice.js"></script> <script src="/js/voice.js?v=0.3.3"></script>
<script src="/js/admin.js"></script> <script src="/js/admin.js?v=0.3.3"></script>
</body> </body>
</html> </html>
+8 -5
View File
@@ -7,8 +7,10 @@
<meta name="description" content="FluentGerman.ai — Chat with your personal German tutor"> <meta name="description" content="FluentGerman.ai — Chat with your personal German tutor">
<title>FluentGerman.ai — Chat</title> <title>FluentGerman.ai — Chat</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <!-- Pinned: an unpinned tag silently follows major releases, and this site
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script> goes months between visits. Bump deliberately, not by accident. -->
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.14/dist/purify.min.js"></script>
</head> </head>
<body> <body>
@@ -17,6 +19,7 @@
<div class="logo">FluentGerman.ai</div> <div class="logo">FluentGerman.ai</div>
<div class="navbar-right"> <div class="navbar-right">
<span class="navbar-user" id="user-name"></span> <span class="navbar-user" id="user-name"></span>
<button class="btn btn-sm btn-secondary" id="new-chat-btn">New chat</button>
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button> <button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
</div> </div>
</nav> </nav>
@@ -70,9 +73,9 @@
</div> </div>
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js?v=0.3.3"></script>
<script src="/js/voice.js"></script> <script src="/js/voice.js?v=0.3.3"></script>
<script src="/js/chat.js"></script> <script src="/js/chat.js?v=0.3.3"></script>
</body> </body>
</html> </html>
+9
View File
@@ -398,6 +398,8 @@ textarea {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 57px); height: calc(100vh - 57px);
/* dvh follows the mobile toolbar as it collapses; vh above is the fallback */
height: calc(100dvh - 57px);
} }
.chat-messages { .chat-messages {
@@ -1040,6 +1042,13 @@ tr:hover td {
padding: 12px 16px; padding: 12px 16px;
} }
/* iOS Safari zooms the whole page in when a focused field is under 16px */
input,
select,
textarea {
font-size: 16px;
}
.message { .message {
max-width: 88%; max-width: 88%;
} }
+3 -3
View File
@@ -30,11 +30,11 @@
<button type="submit" class="btn btn-primary btn-block">Sign In</button> <button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form> </form>
</div> </div>
<span class="version-label">v0.3.1</span> <span class="version-label">v0.3.3</span>
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js?v=0.3.3"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js?v=0.3.3"></script>
</body> </body>
</html> </html>
+53 -16
View File
@@ -6,6 +6,16 @@ document.addEventListener('DOMContentLoaded', () => {
const user = getUser(); const user = getUser();
document.getElementById('admin-name').textContent = user?.username || 'Admin'; 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
// id → username, so instructions can show who they belong to
let usersById = new Map();
// Tab switching // Tab switching
const tabs = document.querySelectorAll('.tab'); const tabs = document.querySelectorAll('.tab');
const panels = document.querySelectorAll('.tab-panel'); const panels = document.querySelectorAll('.tab-panel');
@@ -16,6 +26,11 @@ document.addEventListener('DOMContentLoaded', () => {
panels.forEach(p => p.classList.add('hidden')); panels.forEach(p => p.classList.add('hidden'));
tab.classList.add('active'); tab.classList.add('active');
document.getElementById(tab.dataset.panel).classList.remove('hidden'); 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() { async function loadUsers() {
try { try {
const users = await apiJSON('/users/'); const users = await apiJSON('/users/');
usersById = new Map(users.map(u => [u.id, u.username]));
usersBody.innerHTML = ''; usersBody.innerHTML = '';
if (users.length === 0) { if (users.length === 0) {
@@ -38,13 +54,14 @@ document.addEventListener('DOMContentLoaded', () => {
users.forEach(u => { users.forEach(u => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${u.username}</td> <td>${escapeHtml(u.username)}</td>
<td class="hide-mobile">${u.email}</td> <td class="hide-mobile">${escapeHtml(u.email)}</td>
<td><span class="badge ${u.is_active ? 'badge-personal' : 'badge-homework'}">${u.is_active ? 'Active' : 'Inactive'}</span></td> <td><span class="badge ${u.is_active ? 'badge-personal' : 'badge-homework'}">${u.is_active ? 'Active' : 'Inactive'}</span></td>
<td class="hide-mobile">${new Date(u.created_at).toLocaleDateString()}</td> <td class="hide-mobile">${new Date(u.created_at).toLocaleDateString()}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="editUser(${u.id})">Edit</button> <button class="btn btn-sm btn-secondary" onclick="editUser(${u.id})">Edit</button>
<button class="btn btn-sm btn-secondary" onclick="manageInstructions(${u.id}, '${u.username}')">📝</button> <button class="btn btn-sm btn-secondary" data-username="${escapeHtml(u.username)}"
onclick="manageInstructions(${u.id}, this.dataset.username)">📝</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">✕</button> <button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">✕</button>
</td> </td>
`; `;
@@ -120,7 +137,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.deleteUser = async (id) => { window.deleteUser = async (id) => {
if (!confirm('Delete this client? This will also remove all their instructions.')) return; if (!confirm('Delete this client? This will also remove all their instructions.')) return;
try { try {
await api(`/users/${id}`, { method: 'DELETE' }); await apiVoid(`/users/${id}`, { method: 'DELETE' });
showToast('Client deleted'); showToast('Client deleted');
loadUsers(); loadUsers();
} catch (e) { } catch (e) {
@@ -150,10 +167,10 @@ document.addEventListener('DOMContentLoaded', () => {
instructions.forEach(inst => { instructions.forEach(inst => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${inst.title}</td> <td>${escapeHtml(inst.title)}</td>
<td><span class="badge badge-${inst.type}">${inst.type}</span></td> <td><span class="badge badge-${inst.type}">${inst.type}</span></td>
<td class="hide-mobile">${inst.user_id || 'Global'}</td> <td class="hide-mobile">${inst.user_id ? escapeHtml(usersById.get(inst.user_id) || `#${inst.user_id}`) : 'Global'}</td>
<td class="hide-mobile">${inst.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''}</td> <td class="hide-mobile">${escapeHtml(inst.content.substring(0, 60))}${inst.content.length > 60 ? '...' : ''}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="editInstruction(${inst.id})">Edit</button> <button class="btn btn-sm btn-secondary" onclick="editInstruction(${inst.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteInstruction(${inst.id})">✕</button> <button class="btn btn-sm btn-danger" onclick="deleteInstruction(${inst.id})">✕</button>
@@ -171,21 +188,39 @@ document.addEventListener('DOMContentLoaded', () => {
const users = await apiJSON('/users/'); const users = await apiJSON('/users/');
instrUserSelect.innerHTML = '<option value="">Global (all clients)</option>'; instrUserSelect.innerHTML = '<option value="">Global (all clients)</option>';
users.forEach(u => { users.forEach(u => {
instrUserSelect.innerHTML += `<option value="${u.id}">${u.username}</option>`; instrUserSelect.innerHTML += `<option value="${u.id}">${escapeHtml(u.username)}</option>`;
}); });
} catch (e) { } catch (e) {
// silently fail // silently fail
} }
} }
window.manageInstructions = (userId, username) => { function applyInstructionFilter(userId, username) {
currentFilterUserId = userId; 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 // Switch to instructions tab
tabs.forEach(t => t.classList.remove('active')); tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.add('hidden')); panels.forEach(p => p.classList.add('hidden'));
document.querySelector('[data-panel="instructions-panel"]').classList.add('active'); document.querySelector('[data-panel="instructions-panel"]').classList.add('active');
document.getElementById('instructions-panel').classList.remove('hidden'); document.getElementById('instructions-panel').classList.remove('hidden');
loadInstructions(userId); applyInstructionFilter(userId, username);
}; };
window.showInstructionModal = () => { window.showInstructionModal = () => {
@@ -253,7 +288,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.deleteInstruction = async (id) => { window.deleteInstruction = async (id) => {
if (!confirm('Delete this instruction?')) return; if (!confirm('Delete this instruction?')) return;
try { try {
await api(`/instructions/${id}`, { method: 'DELETE' }); await apiVoid(`/instructions/${id}`, { method: 'DELETE' });
showToast('Instruction deleted'); showToast('Instruction deleted');
loadInstructions(currentFilterUserId); loadInstructions(currentFilterUserId);
} catch (e) { } catch (e) {
@@ -271,9 +306,11 @@ document.addEventListener('DOMContentLoaded', () => {
if (!file) return; if (!file) return;
const text = await file.text(); 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-title').value = file.name.replace(/\.[^.]+$/, '');
document.getElementById('instr-content').value = text; document.getElementById('instr-content').value = text;
showInstructionModal();
}; };
input.click(); input.click();
}; };
@@ -328,14 +365,14 @@ document.addEventListener('DOMContentLoaded', () => {
const content = voiceGenText.value.trim(); const content = voiceGenText.value.trim();
if (!content) return; 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-content').value = content;
document.getElementById('instr-title').value = 'Voice Generated Instruction'; document.getElementById('instr-title').value = 'Voice Generated Instruction';
showInstructionModal();
}; };
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
loadUsers(); loadUsers().then(() => loadInstructions());
loadInstructions();
document.getElementById('logout-btn').addEventListener('click', logout); document.getElementById('logout-btn').addEventListener('click', logout);
}); });
+19 -5
View File
@@ -52,15 +52,27 @@ async function api(path, options = {}) {
return response; return response;
} }
async function apiJSON(path, options = {}) { async function ensureOk(response) {
const response = await api(path, options); // api() returns undefined after a 401 — the redirect is already under way
if (!response || !response.ok) { if (!response) throw new Error('Session expired');
const error = await response?.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || 'Request failed'); 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(); 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() { function requireAuth() {
if (!getToken()) { if (!getToken()) {
window.location.href = '/'; window.location.href = '/';
@@ -81,6 +93,8 @@ function requireAdmin() {
function logout() { function logout() {
clearToken(); clearToken();
clearUser(); 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 = '/'; window.location.href = '/';
} }
+187 -61
View File
@@ -58,6 +58,15 @@ document.addEventListener('DOMContentLoaded', async () => {
let history = []; let history = [];
let voiceModeOn = false; 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 ────────────────────────────────────────── // ── Personalised welcome ──────────────────────────────────────────
const greetingEl = document.getElementById('welcome-greeting'); const greetingEl = document.getElementById('welcome-greeting');
@@ -156,14 +165,123 @@ document.addEventListener('DOMContentLoaded', async () => {
return div; 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() { async function sendMessage() {
if (sending) return;
const text = inputEl.value.trim(); const text = inputEl.value.trim();
if (!text) return; if (!text) return;
setSending(true);
const controller = new AbortController();
abortController = controller;
const generation = chatGeneration;
const isCurrent = () => generation === chatGeneration;
voice.lastInputWasVoice = false; voice.lastInputWasVoice = false;
inputEl.value = ''; inputEl.value = '';
sendBtn.disabled = true;
appendMessage('user', text); appendMessage('user', text);
history.push({ role: 'user', content: text }); history.push({ role: 'user', content: text });
@@ -175,6 +293,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const response = await api('/chat/', { const response = await api('/chat/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ message: text, history: history.slice(-20) }), body: JSON.stringify({ message: text, history: history.slice(-20) }),
signal: controller.signal,
}); });
if (!response?.ok) { if (!response?.ok) {
@@ -182,94 +301,96 @@ document.addEventListener('DOMContentLoaded', async () => {
throw new Error(errData.detail || `Chat failed (${response?.status})`); 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) { 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) { if (!isCurrent()) return;
const { done, value } = await reader.read(); assistantEl.classList.remove('message-thinking');
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) { }
}
}
}
// Text complete. Now fetch audio.
if (fullResponse) { if (fullResponse) {
history.push({ role: 'assistant', content: fullResponse }); history.push({ role: 'assistant', content: fullResponse });
saveHistory();
// Keep "Thinking..." until audio is ready or failed // Same signal as the chat request, so Stop cancels TTS too
const audioUrl = await voice.fetchAudio(fullResponse); const audioUrl = await voice.fetchAudio(fullResponse, controller.signal);
if (!isCurrent()) return;
// Visual update: Remove thinking, show text // The reply itself is complete — show it even when Stop
assistantEl.classList.remove('message-thinking'); // cancelled the audio
assistantEl.innerHTML = renderMarkdown(fullResponse); assistantEl.innerHTML = renderMarkdown(fullResponse);
messagesEl.scrollTop = messagesEl.scrollHeight; 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); await voice.playAudio(audioUrl, assistantEl);
} }
}
} else { } else {
// Normal Text Mode: Stream directly to UI assistantEl.textContent = 'No response received. Please try again.';
while (true) { }
const { done, value } = await reader.read(); } else {
if (done) break; // Text mode: stream straight into the bubble
await readSSE(response, (token) => {
const chunk = decoder.decode(value); fullResponse += token;
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); assistantEl.innerHTML = renderMarkdown(fullResponse);
messagesEl.scrollTop = messagesEl.scrollHeight; messagesEl.scrollTop = messagesEl.scrollHeight;
} });
if (parsed.error) {
showToast(parsed.error, 'error'); if (!isCurrent()) return;
}
} catch (e) {
// skip unparseable chunks
}
}
}
}
if (fullResponse) { if (fullResponse) {
history.push({ role: 'assistant', content: fullResponse }); history.push({ role: 'assistant', content: fullResponse });
saveHistory();
} else {
assistantEl.textContent = 'No response received. Please try again.';
} }
} }
} catch (e) { } catch (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.'; assistantEl.textContent = 'Sorry, something went wrong. Please try again.';
showToast(e.message, 'error'); showToast(e.message, 'error');
console.error('[Chat] Error:', e); console.error('[Chat] Error:', e);
} }
} finally {
sendBtn.disabled = false; // 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(); inputEl.focus();
} }
}
sendBtn.addEventListener('click', sendMessage); sendBtn.addEventListener('click', () => {
if (sending) {
abortController?.abort();
return;
}
sendMessage();
});
inputEl.addEventListener('keydown', (e) => { inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -277,6 +398,11 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
}); });
document.getElementById('new-chat-btn')?.addEventListener('click', startNewChat);
// Logout // Logout
document.getElementById('logout-btn').addEventListener('click', logout); document.getElementById('logout-btn').addEventListener('click', logout);
// Bring back this session's conversation, if there is one
restoreHistory();
}); });
+125 -13
View File
@@ -14,6 +14,9 @@ class VoiceManager {
this.browserSTTSupported = false; this.browserSTTSupported = false;
this.apiAvailable = false; this.apiAvailable = false;
this.onProcessing = null; // New callback for "Transcribing..." state 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() { async init() {
@@ -98,10 +101,18 @@ class VoiceManager {
// if hardware access fails or takes time. // if hardware access fails or takes time.
if (this.mode === 'api') { if (this.mode === 'api') {
if (typeof MediaRecorder === 'undefined') {
showToast('This browser cannot record audio.', 'error');
return;
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.audioChunks = []; 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) => { this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.audioChunks.push(e.data); if (e.data.size > 0) this.audioChunks.push(e.data);
@@ -109,7 +120,11 @@ class VoiceManager {
this.mediaRecorder.onstop = async () => { this.mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop()); 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); await this._transcribeAPI(blob);
}; };
@@ -124,7 +139,12 @@ class VoiceManager {
} catch (e) { } catch (e) {
console.error('[Voice] Mic access error:', 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; this.isRecording = false;
if (this.onStateChange) this.onStateChange(false); if (this.onStateChange) this.onStateChange(false);
} }
@@ -175,7 +195,9 @@ class VoiceManager {
try { try {
const formData = new FormData(); 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', { const response = await api('/voice/transcribe', {
method: 'POST', method: 'POST',
@@ -204,13 +226,15 @@ class VoiceManager {
* Fetch TTS audio blob for text (API only). * Fetch TTS audio blob for text (API only).
* Returns audio URL or null. * Returns audio URL or null.
*/ */
async fetchAudio(text) { async fetchAudio(text, signal) {
if (!this.apiAvailable) return null; if (!this.apiAvailable) return null;
const clean = VoiceManager.stripMarkdown(text); const clean = VoiceManager.stripMarkdown(text);
try { try {
const response = await api(`/voice/synthesize?text=${encodeURIComponent(clean)}`, { const response = await api('/voice/synthesize', {
method: 'POST', method: 'POST',
body: JSON.stringify({ text: clean }),
signal,
}); });
if (response?.ok) { if (response?.ok) {
@@ -235,7 +259,13 @@ class VoiceManager {
async playAudio(audioUrl, containerEl) { async playAudio(audioUrl, containerEl) {
if (!audioUrl) return; if (!audioUrl) return;
this.stopPlayback(); // only one clip at a time
const audio = new Audio(audioUrl); 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 // Visual feedback — avatar pulse
const avatarContainer = document.querySelector('.avatar-container'); const avatarContainer = document.querySelector('.avatar-container');
@@ -268,6 +298,15 @@ class VoiceManager {
containerEl.appendChild(player); 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 ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
function fmt(s) { function fmt(s) {
if (!isFinite(s)) return '0:00'; if (!isFinite(s)) return '0:00';
@@ -301,6 +340,10 @@ class VoiceManager {
// Play/pause toggle // Play/pause toggle
playBtn.addEventListener('click', () => { playBtn.addEventListener('click', () => {
if (audio.paused) { if (audio.paused) {
// Register the replay, otherwise stopPlayback() can't reach it
this.stopPlayback();
this.currentAudio = audio;
this._resetPlayerUI = resetPlayerUI;
audio.play(); audio.play();
playBtn.classList.add('playing'); playBtn.classList.add('playing');
playBtn.innerHTML = VoiceManager._pauseIcon(); playBtn.innerHTML = VoiceManager._pauseIcon();
@@ -308,6 +351,10 @@ class VoiceManager {
if (avatarContainer) avatarContainer.classList.add('speaking'); if (avatarContainer) avatarContainer.classList.add('speaking');
} else { } else {
audio.pause(); audio.pause();
if (this.currentAudio === audio) {
this.currentAudio = null;
this._resetPlayerUI = null;
}
playBtn.classList.remove('playing'); playBtn.classList.remove('playing');
playBtn.innerHTML = VoiceManager._playIcon(); playBtn.innerHTML = VoiceManager._playIcon();
playBtn.title = 'Play'; playBtn.title = 'Play';
@@ -317,15 +364,22 @@ class VoiceManager {
// ── Playback ────────────────────────────────────────────────── // ── Playback ──────────────────────────────────────────────────
try { try {
// Wait for audio to be fully buffered before playing // Wait for audio to be fully buffered before playing — but give up
await new Promise((resolve, reject) => { // if stopPlayback() cuts in while it is still loading
await Promise.race([
new Promise((resolve, reject) => {
audio.addEventListener('canplaythrough', resolve, { once: true }); audio.addEventListener('canplaythrough', resolve, { once: true });
audio.addEventListener('error', reject, { once: true }); audio.addEventListener('error', reject, { once: true });
audio.load(); // Explicitly trigger loading audio.load(); // Explicitly trigger loading
}); }),
finished,
]);
if (this.currentAudio !== audio) return; // stopped while loading
audio.currentTime = 0; // Ensure we start from the very beginning audio.currentTime = 0; // Ensure we start from the very beginning
await audio.play(); await audio.play();
return new Promise(resolve => {
audio.onended = () => { audio.onended = () => {
if (avatarContainer) avatarContainer.classList.remove('speaking'); if (avatarContainer) avatarContainer.classList.remove('speaking');
playBtn.classList.remove('playing'); playBtn.classList.remove('playing');
@@ -334,21 +388,54 @@ class VoiceManager {
fill.style.width = '100%'; fill.style.width = '100%';
// Reset to beginning for replay // Reset to beginning for replay
audio.currentTime = 0; audio.currentTime = 0;
resolve(); this._settlePlayback();
}; };
audio.onerror = () => { audio.onerror = () => {
if (avatarContainer) avatarContainer.classList.remove('speaking'); if (avatarContainer) avatarContainer.classList.remove('speaking');
resolve(); this._settlePlayback();
}; };
}); return finished;
} catch (e) { } catch (e) {
console.error('Playback failed', e); console.error('Playback failed', e);
if (avatarContainer) avatarContainer.classList.remove('speaking'); if (avatarContainer) avatarContainer.classList.remove('speaking');
playBtn.classList.remove('playing'); playBtn.classList.remove('playing');
playBtn.innerHTML = VoiceManager._playIcon(); 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) ────────────────────────── // ── SVG icons (inline, no external deps) ──────────────────────────
static _playIcon() { static _playIcon() {
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>`; return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>`;
@@ -367,6 +454,31 @@ class VoiceManager {
if (url) await this.playAudio(url); 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. * Strip markdown formatting from text so TTS reads naturally.
*/ */