initial commit

This commit is contained in:
2026-02-12 18:45:10 +01:00
commit be7bbba456
42 changed files with 3767 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
name: Deploy FluentGerman.ai
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
- name: Run tests
run: |
cd backend
source venv/bin/activate
pip install aiosqlite
python -m pytest tests/ -v --tb=short
- name: Deploy to server
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
run: |
# Write SSH credentials
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
SSH_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no"
# Sync backend (excluding .env and venv)
rsync -avz --delete \
--exclude '.env' \
--exclude 'venv/' \
--exclude '__pycache__/' \
--exclude 'logs/' \
--exclude '*.pyc' \
-e "ssh $SSH_OPTS" \
backend/ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/backend/
# Sync frontend
rsync -avz --delete \
-e "ssh $SSH_OPTS" \
frontend/ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/frontend/
# Install deps & restart on server
ssh $SSH_OPTS ${DEPLOY_USER}@${DEPLOY_HOST} << REMOTE_SCRIPT
set -e
cd ${DEPLOY_PATH}/backend
# Create venv if not exists
if [ ! -d "venv" ]; then
python3 -m venv venv
fi
source venv/bin/activate
pip install --quiet -r requirements.txt
# Restart service
sudo systemctl restart fluentgerman
echo "✓ FluentGerman.ai deployed and restarted"
REMOTE_SCRIPT
# Cleanup
rm -f ~/.ssh/deploy_key

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Byte-compiled
__pycache__/
*.py[cod]
# Virtual env
venv/
.venv/
# Environment
.env
*.db
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Test artifacts
test.db
.pytest_cache/
htmlcov/
.coverage
# Logs
logs/

107
README.md Normal file
View File

@@ -0,0 +1,107 @@
# FluentGerman.ai
**Personalized AI-powered German language learning platform.**
A web-based tool for a German language teacher to provide clients with customized LLM-powered tutoring through personalized instructions, voice interaction, and a clean admin panel.
## Features
- 🔐 **User Management** — Admin creates clients with username, email, password
- 📝 **Custom Instructions** — Global teaching method + per-client instructions + homework
- 💬 **AI Chat** — Streaming LLM responses with personalized system prompts
- 🎤 **Voice Mode** — Speech-to-text & text-to-speech (API or browser fallback)
- 🛠 **Admin Panel** — Manage users, upload instruction files, voice-to-instruction generator
- 🔄 **Flexible LLM** — Swap OpenAI, Anthropic, or 100+ providers via LiteLLM
- 📱 **Mobile Ready** — Responsive design, works on all devices
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.12 + FastAPI |
| Database | PostgreSQL (async via SQLAlchemy + asyncpg) |
| Auth | JWT + bcrypt |
| LLM | LiteLLM (provider-agnostic) |
| Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) |
| Frontend | Vanilla HTML/CSS/JS |
| Deployment | systemd + nginx on Debian |
## Quick Start (Development)
```bash
# 1. Clone & setup backend
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# 2. Configure
cp .env.example .env
# Edit .env with your API keys, database URL, and admin password
# 3. Run
uvicorn app.main:app --reload
# Open http://localhost:8000
```
## Production Deployment (Debian)
```bash
# Prerequisites: PostgreSQL and nginx already installed
sudo bash deploy/setup.sh
# Then edit the .env file:
sudo nano /opt/fluentgerman/backend/.env
# Restart after config changes:
sudo systemctl restart fluentgerman
```
## Project Structure
```
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── config.py # Environment-based settings
│ │ ├── database.py # Async PostgreSQL setup
│ │ ├── models.py # User & Instruction models
│ │ ├── schemas.py # Pydantic request/response
│ │ ├── auth.py # JWT + bcrypt + dependencies
│ │ ├── routers/ # API endpoints
│ │ └── services/ # LLM, voice, instruction logic
│ ├── tests/ # pytest async tests
│ ├── requirements.txt
│ └── .env.example
├── frontend/
│ ├── index.html # Login
│ ├── chat.html # Client chat + voice
│ ├── admin.html # Admin dashboard
│ ├── css/style.css # Design system
│ └── js/ # Modules (api, auth, chat, voice, admin)
└── deploy/ # systemd, nginx, setup script
```
## Running Tests
```bash
cd backend
pip install aiosqlite # needed for test SQLite backend
python -m pytest tests/ -v
```
## Configuration
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 |
| `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) |
## License
Private — All rights reserved.

33
backend/.env.example Normal file
View File

@@ -0,0 +1,33 @@
# FluentGerman.ai — Environment Configuration
# Copy to .env and fill in your values
# App
APP_PORT=8999
# Security
SECRET_KEY=generate-a-strong-random-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Database (PostgreSQL)
DATABASE_URL=postgresql+asyncpg://fluentgerman:YOUR_PASSWORD@localhost:5432/fluentgerman
# LLM Provider (via LiteLLM — supports openai, anthropic, gemini, etc.)
# For Gemini: set LLM_PROVIDER=gemini, LLM_MODEL=gemini-2.0-flash (auto-prefixed)
# For OpenAI: set LLM_PROVIDER=openai, LLM_MODEL=gpt-4o-mini
LLM_PROVIDER=gemini
LLM_API_KEY=your-api-key-here
LLM_MODEL=gemini-2.0-flash
# Voice mode: "api" (OpenAI Whisper/TTS) or "browser" (Web Speech API fallback)
VOICE_MODE=browser
TTS_MODEL=tts-1
TTS_VOICE=alloy
STT_MODEL=whisper-1
# Admin bootstrap (only used on first startup)
ADMIN_EMAIL=admin@fluentgerman.ai
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-immediately
# Deployment domain (used in nginx template)
# APP_DOMAIN=fluentgerman.mydomain.io

0
backend/app/__init__.py Normal file
View File

61
backend/app/auth.py Normal file
View File

@@ -0,0 +1,61 @@
"""FluentGerman.ai — Authentication & authorization utilities."""
from datetime import datetime, timedelta, timezone
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(data: dict) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {**data, "exp": expire}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, get_settings().secret_key, algorithms=[get_settings().algorithm])
user_id: int | None = payload.get("sub")
if user_id is None:
raise credentials_exc
except JWTError:
raise credentials_exc
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise credentials_exc
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

45
backend/app/config.py Normal file
View File

@@ -0,0 +1,45 @@
"""FluentGerman.ai — Application configuration."""
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""App settings loaded from environment / .env file."""
# App
app_name: str = "FluentGerman.ai"
debug: bool = False
# Security
secret_key: str = "CHANGE-ME-in-production"
access_token_expire_minutes: int = 60 * 24 # 24h
algorithm: str = "HS256"
# Database
database_url: str = "postgresql+asyncpg://fluentgerman:fluentgerman@localhost:5432/fluentgerman"
# LLM
llm_provider: str = "openai" # used by litellm routing
llm_api_key: str = ""
llm_model: str = "gpt-4o-mini"
# Voice feature flag: "api" = LLM provider Whisper/TTS, "browser" = Web Speech API
voice_mode: Literal["api", "browser"] = "api"
tts_model: str = "tts-1"
tts_voice: str = "alloy"
stt_model: str = "whisper-1"
# Admin bootstrap
admin_email: str = "admin@fluentgerman.ai"
admin_username: str = "admin"
admin_password: str = "CHANGE-ME"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@lru_cache
def get_settings() -> Settings:
return Settings()

20
backend/app/database.py Normal file
View File

@@ -0,0 +1,20 @@
"""FluentGerman.ai — Database setup (async PostgreSQL)."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
engine = create_async_engine(get_settings().database_url, echo=get_settings().debug)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session

98
backend/app/main.py Normal file
View File

@@ -0,0 +1,98 @@
"""FluentGerman.ai — FastAPI application entry point."""
import logging
import time
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 app.auth import hash_password
from app.config import get_settings
from app.database import Base, engine, async_session
from app.models import User
from app.routers import auth, chat, instructions, users, voice
# ── Logging ──────────────────────────────────────────────────────────
import os
os.makedirs("logs", exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("logs/app.log", mode="a"),
],
)
logger = logging.getLogger("fluentgerman")
@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)
# Bootstrap admin if not exists
settings = get_settings()
async with async_session() as db:
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
email=settings.admin_email,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
logger.info("Admin user created: %s", settings.admin_username)
logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
settings.llm_provider, settings.llm_model, settings.voice_mode)
yield
logger.info("FluentGerman.ai shutting down")
app = FastAPI(
title="FluentGerman.ai",
description="Personalized LLM-powered German language learning platform",
version="1.0.0",
lifespan=lifespan,
)
# ── Request logging middleware ────────────────────────────────────────
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = round((time.time() - start) * 1000)
# Skip logging static file requests to keep logs clean
path = request.url.path
if path.startswith("/api/"):
logger.info("%s %s%s (%dms)", request.method, path, response.status_code, duration)
return response
# CORS — restrict in production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(instructions.router)
app.include_router(chat.router)
app.include_router(voice.router)
# Serve frontend static files
app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")

52
backend/app/models.py Normal file
View File

@@ -0,0 +1,52 @@
"""FluentGerman.ai — Database models."""
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class InstructionType(str, enum.Enum):
GLOBAL = "global"
PERSONAL = "personal"
HOMEWORK = "homework"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
instructions: Mapped[list["Instruction"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Instruction(Base):
__tablename__ = "instructions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text)
type: Mapped[InstructionType] = mapped_column(
Enum(InstructionType), default=InstructionType.PERSONAL
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user: Mapped[User | None] = relationship(back_populates="instructions")

View File

View File

@@ -0,0 +1,32 @@
"""FluentGerman.ai — Auth router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import create_access_token, get_current_user, verify_password
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, Token, UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=Token)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
token = create_access_token({"sub": str(user.id)})
return Token(access_token=token)
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user

View File

@@ -0,0 +1,48 @@
"""FluentGerman.ai — Chat router with SSE streaming."""
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user
from app.database import get_db
from app.models import User
from app.schemas import ChatRequest
from app.services.instruction_service import get_system_prompt
from app.services.llm_service import chat_stream
logger = logging.getLogger("fluentgerman.chat")
router = APIRouter(prefix="/api/chat", tags=["chat"])
@router.post("/")
async def chat(
body: ChatRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Send a message and receive a streamed SSE response."""
logger.info("Chat request from user=%s message_len=%d history=%d",
user.username, len(body.message), len(body.history))
system_prompt = await get_system_prompt(db, user.id)
messages = [{"role": "system", "content": system_prompt}]
for msg in body.history:
messages.append({"role": msg.role, "content": msg.content})
messages.append({"role": "user", "content": body.message})
async def event_generator():
try:
async for token in chat_stream(messages):
yield f"data: {json.dumps({'token': token})}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.error("LLM streaming error: %s", e, exc_info=True)
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -0,0 +1,68 @@
"""FluentGerman.ai — Instruction management router (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_admin
from app.database import get_db
from app.models import Instruction, InstructionType
from app.schemas import InstructionCreate, InstructionOut, InstructionUpdate
router = APIRouter(prefix="/api/instructions", tags=["instructions"], dependencies=[Depends(require_admin)])
@router.get("/", response_model=list[InstructionOut])
async def list_instructions(user_id: int | None = None, db: AsyncSession = Depends(get_db)):
query = select(Instruction).order_by(Instruction.created_at.desc())
if user_id is not None:
# Fetch per-user + global instructions
query = query.where((Instruction.user_id == user_id) | Instruction.user_id.is_(None))
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=InstructionOut, status_code=status.HTTP_201_CREATED)
async def create_instruction(body: InstructionCreate, db: AsyncSession = Depends(get_db)):
instruction = Instruction(
user_id=body.user_id,
title=body.title,
content=body.content,
type=InstructionType(body.type),
)
db.add(instruction)
await db.commit()
await db.refresh(instruction)
return instruction
@router.put("/{instruction_id}", response_model=InstructionOut)
async def update_instruction(
instruction_id: int, body: InstructionUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
if body.title is not None:
inst.title = body.title
if body.content is not None:
inst.content = body.content
if body.type is not None:
inst.type = InstructionType(body.type)
await db.commit()
await db.refresh(inst)
return inst
@router.delete("/{instruction_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_instruction(instruction_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
await db.delete(inst)
await db.commit()

View File

@@ -0,0 +1,70 @@
"""FluentGerman.ai — User management router (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import hash_password, require_admin
from app.database import get_db
from app.models import User
from app.schemas import UserCreate, UserOut, UserUpdate
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(require_admin)])
@router.get("/", response_model=list[UserOut])
async def list_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.is_admin == False).order_by(User.created_at.desc())) # noqa: E712
return result.scalars().all()
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(body: UserCreate, db: AsyncSession = Depends(get_db)):
# Check uniqueness
existing = await db.execute(
select(User).where((User.username == body.username) | (User.email == body.email))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username or email already exists")
user = User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserOut)
async def update_user(user_id: int, body: UserUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if body.username is not None:
user.username = body.username
if body.email is not None:
user.email = body.email
if body.password is not None:
user.hashed_password = hash_password(body.password)
if body.is_active is not None:
user.is_active = body.is_active
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await db.delete(user)
await db.commit()

View File

@@ -0,0 +1,49 @@
"""FluentGerman.ai — Admin voice-to-instruction & voice API router."""
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_admin, get_current_user
from app.config import get_settings
from app.database import get_db
from app.models import User
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
from app.services.llm_service import summarize_instruction
from app.services.voice_service import synthesize, transcribe
from fastapi.responses import Response
router = APIRouter(prefix="/api/voice", tags=["voice"])
@router.get("/config", response_model=VoiceConfigOut)
async def voice_config(user: User = Depends(get_current_user)):
"""Return current voice mode so frontend knows whether to use browser or API."""
return VoiceConfigOut(voice_mode=get_settings().voice_mode)
@router.post("/transcribe")
async def transcribe_audio(
audio: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""Transcribe uploaded audio to text (API mode only)."""
audio_bytes = await audio.read()
text = await transcribe(audio_bytes, filename=audio.filename or "audio.webm")
return {"text": text}
@router.post("/synthesize")
async def synthesize_text(
text: str,
user: User = Depends(get_current_user),
):
"""Convert text to speech audio (API mode only)."""
audio_bytes = await synthesize(text)
return Response(content=audio_bytes, media_type="audio/mpeg")
@router.post("/generate-instruction", dependencies=[Depends(require_admin)])
async def generate_instruction(body: VoiceInstructionRequest):
"""Admin: takes raw transcript and returns a structured instruction via LLM."""
structured = await summarize_instruction(body.raw_text)
return {"instruction": structured}

86
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,86 @@
"""FluentGerman.ai — Pydantic request/response schemas."""
from datetime import datetime
from pydantic import BaseModel, EmailStr
# ── Auth ──────────────────────────────────────────────────────────────
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
username: str
password: str
# ── User ──────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserUpdate(BaseModel):
username: str | None = None
email: EmailStr | None = None
password: str | None = None
is_active: bool | None = None
class UserOut(BaseModel):
id: int
username: str
email: str
is_admin: bool
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
# ── Instruction ───────────────────────────────────────────────────────
class InstructionCreate(BaseModel):
user_id: int | None = None
title: str
content: str
type: str = "personal" # global | personal | homework
class InstructionUpdate(BaseModel):
title: str | None = None
content: str | None = None
type: str | None = None
class InstructionOut(BaseModel):
id: int
user_id: int | None
title: str
content: str
type: str
created_at: datetime
model_config = {"from_attributes": True}
# ── Chat ──────────────────────────────────────────────────────────────
class ChatMessage(BaseModel):
role: str # "user" | "assistant"
content: str
class ChatRequest(BaseModel):
message: str
history: list[ChatMessage] = []
# ── Voice / Admin ────────────────────────────────────────────────────
class VoiceInstructionRequest(BaseModel):
raw_text: str
class VoiceConfigOut(BaseModel):
voice_mode: str # "api" | "browser"

View File

View File

@@ -0,0 +1,52 @@
"""FluentGerman.ai — Instruction assembly service."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Instruction, InstructionType
async def get_system_prompt(db: AsyncSession, user_id: int) -> str:
"""Assemble the full system prompt from global + personal + homework instructions."""
# Global instructions (no user_id)
result = await db.execute(
select(Instruction).where(
Instruction.user_id.is_(None),
Instruction.type == InstructionType.GLOBAL,
)
)
global_instructions = result.scalars().all()
# Personal + homework for this user
result = await db.execute(
select(Instruction).where(Instruction.user_id == user_id)
)
user_instructions = result.scalars().all()
parts: list[str] = []
if global_instructions:
parts.append("=== TEACHING METHOD ===")
for inst in global_instructions:
parts.append(f"[{inst.title}]\n{inst.content}")
personal = [i for i in user_instructions if i.type == InstructionType.PERSONAL]
if personal:
parts.append("\n=== PERSONAL INSTRUCTIONS ===")
for inst in personal:
parts.append(f"[{inst.title}]\n{inst.content}")
homework = [i for i in user_instructions if i.type == InstructionType.HOMEWORK]
if homework:
parts.append("\n=== CURRENT HOMEWORK ===")
for inst in homework:
parts.append(f"[{inst.title}]\n{inst.content}")
if not parts:
return (
"You are a helpful German language tutor. Help the student learn German "
"through conversation, corrections, and explanations."
)
return "\n\n".join(parts)

View File

@@ -0,0 +1,65 @@
"""FluentGerman.ai — LLM service (provider-agnostic via LiteLLM)."""
import logging
from collections.abc import AsyncGenerator
import litellm
from app.config import get_settings
logger = logging.getLogger("fluentgerman.llm")
def _resolve_model(model: str, provider: str) -> str:
"""Ensure Gemini models have the 'gemini/' prefix for Google AI Studio."""
if provider == "gemini" and not model.startswith("gemini/"):
resolved = f"gemini/{model}"
logger.info("Auto-prefixed model: %s%s (Google AI Studio)", model, resolved)
return resolved
return model
async def chat_stream(
messages: list[dict[str, str]],
model: str | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completion tokens from the configured LLM provider."""
settings = get_settings()
model = _resolve_model(model or settings.llm_model, settings.llm_provider)
logger.info("LLM request: model=%s messages=%d", model, len(messages))
response = await litellm.acompletion(
model=model,
messages=messages,
api_key=settings.llm_api_key,
stream=True,
)
async for chunk in response:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
async def summarize_instruction(raw_text: str) -> str:
"""Ask the LLM to distill raw voice transcript into a structured instruction."""
settings = get_settings()
model = _resolve_model(settings.llm_model, settings.llm_provider)
meta_prompt = (
"You are an expert assistant for a language teacher. "
"The following text is a raw transcript of the teacher describing a learning instruction, "
"homework, or teaching method. Distill it into a clear, concise, structured instruction "
"that can be used as a system prompt for a language-learning LLM assistant. "
"Output ONLY the instruction text, no preamble.\n\n"
f"Transcript:\n{raw_text}"
)
response = await litellm.acompletion(
model=model,
messages=[{"role": "user", "content": meta_prompt}],
api_key=settings.llm_api_key,
)
return response.choices[0].message.content.strip()

View File

@@ -0,0 +1,37 @@
"""FluentGerman.ai — Voice service (API provider + browser fallback)."""
import io
import openai
from app.config import get_settings
async def transcribe(audio_bytes: bytes, filename: str = "audio.webm") -> str:
"""Transcribe audio to text using OpenAI Whisper API."""
settings = get_settings()
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
audio_file = io.BytesIO(audio_bytes)
audio_file.name = filename
transcript = await client.audio.transcriptions.create(
model=settings.stt_model,
file=audio_file,
)
return transcript.text
async def synthesize(text: str) -> bytes:
"""Synthesize text to speech using OpenAI TTS API."""
settings = get_settings()
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
response = await client.audio.speech.create(
model=settings.tts_model,
voice=settings.tts_voice,
input=text,
response_format="mp3",
)
return response.content

2
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"

19
backend/requirements.txt Normal file
View File

@@ -0,0 +1,19 @@
# FluentGerman.ai — Python dependencies
fastapi[standard]>=0.115.0
uvicorn[standard]>=0.32.0
sqlalchemy[asyncio]>=2.0.0
asyncpg>=0.30.0
pydantic[email]>=2.0.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
litellm>=1.50.0
openai>=1.50.0
python-multipart>=0.0.9
httpx>=0.27.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.24.0
aiosqlite>=0.20.0
httpx>=0.27.0

View File

86
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,86 @@
"""FluentGerman.ai — Test configuration & fixtures."""
import asyncio
from collections.abc import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import Settings, get_settings
from app.database import Base, get_db
from app.main import app
# Use SQLite for tests (in-memory)
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
def get_test_settings() -> Settings:
return Settings(
database_url=TEST_DATABASE_URL,
secret_key="test-secret-key",
llm_api_key="test-key",
admin_password="testadmin123",
)
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with test_session() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_settings] = get_test_settings
@pytest_asyncio.fixture(autouse=True)
async def setup_database():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def admin_token(client: AsyncClient) -> str:
"""Create admin and return token."""
from app.auth import hash_password
from app.models import User
async with test_session() as db:
admin = User(
username="admin",
email="admin@test.com",
hashed_password=hash_password("admin123"),
is_admin=True,
)
db.add(admin)
await db.commit()
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
return resp.json()["access_token"]
@pytest_asyncio.fixture
async def user_token(client: AsyncClient, admin_token: str) -> str:
"""Create a regular user via admin API and return their token."""
await client.post(
"/api/users/",
json={"username": "testuser", "email": "user@test.com", "password": "user123"},
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = await client.post("/api/auth/login", json={"username": "testuser", "password": "user123"})
return resp.json()["access_token"]

View File

@@ -0,0 +1,55 @@
"""FluentGerman.ai — Auth tests."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient, admin_token: str):
"""Admin can log in and receives a token."""
assert admin_token is not None
assert len(admin_token) > 20
@pytest.mark.asyncio
async def test_login_wrong_password(client: AsyncClient):
"""Wrong password returns 401."""
from app.auth import hash_password
from app.models import User
from tests.conftest import test_session
async with test_session() as db:
user = User(
username="logintest",
email="logintest@test.com",
hashed_password=hash_password("correct"),
)
db.add(user)
await db.commit()
resp = await client.post("/api/auth/login", json={"username": "logintest", "password": "wrong"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user(client: AsyncClient):
"""Nonexistent user returns 401."""
resp = await client.post("/api/auth/login", json={"username": "nobody", "password": "pass"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_me_endpoint(client: AsyncClient, admin_token: str):
"""Authenticated user can access /me."""
resp = await client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "admin"
assert data["is_admin"] is True
@pytest.mark.asyncio
async def test_me_unauthenticated(client: AsyncClient):
"""Unauthenticated request to /me returns 401."""
resp = await client.get("/api/auth/me")
assert resp.status_code == 401

View File

@@ -0,0 +1,117 @@
"""FluentGerman.ai — Instruction management tests."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_global_instruction(client: AsyncClient, admin_token: str):
"""Admin can create a global instruction."""
headers = {"Authorization": f"Bearer {admin_token}"}
resp = await client.post(
"/api/instructions/",
json={"title": "Teaching Method", "content": "Use immersive conversation", "type": "global"},
headers=headers,
)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "Teaching Method"
assert data["type"] == "global"
assert data["user_id"] is None
@pytest.mark.asyncio
async def test_create_personal_instruction(client: AsyncClient, admin_token: str, user_token: str):
"""Admin can create a personal instruction for a user."""
headers = {"Authorization": f"Bearer {admin_token}"}
# Get user id
users_resp = await client.get("/api/users/", headers=headers)
user_id = users_resp.json()[0]["id"]
resp = await client.post(
"/api/instructions/",
json={
"title": "Focus on verbs",
"content": "This student needs extra practice with irregular verbs.",
"type": "personal",
"user_id": user_id,
},
headers=headers,
)
assert resp.status_code == 201
assert resp.json()["user_id"] == user_id
@pytest.mark.asyncio
async def test_list_instructions_by_user(client: AsyncClient, admin_token: str, user_token: str):
"""Filtering instructions by user returns personal + global."""
headers = {"Authorization": f"Bearer {admin_token}"}
users_resp = await client.get("/api/users/", headers=headers)
user_id = users_resp.json()[0]["id"]
# Create global
await client.post(
"/api/instructions/",
json={"title": "Global Rule", "content": "Always respond in German", "type": "global"},
headers=headers,
)
# Create personal
await client.post(
"/api/instructions/",
json={"title": "Personal", "content": "Focus on A1", "type": "personal", "user_id": user_id},
headers=headers,
)
resp = await client.get(f"/api/instructions/?user_id={user_id}", headers=headers)
assert resp.status_code == 200
instructions = resp.json()
types = [i["type"] for i in instructions]
assert "global" in types
assert "personal" in types
@pytest.mark.asyncio
async def test_update_instruction(client: AsyncClient, admin_token: str):
"""Admin can update an instruction."""
headers = {"Authorization": f"Bearer {admin_token}"}
create_resp = await client.post(
"/api/instructions/",
json={"title": "Old Title", "content": "Old content", "type": "global"},
headers=headers,
)
inst_id = create_resp.json()["id"]
resp = await client.put(
f"/api/instructions/{inst_id}",
json={"title": "New Title", "content": "Updated content"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["title"] == "New Title"
@pytest.mark.asyncio
async def test_delete_instruction(client: AsyncClient, admin_token: str):
"""Admin can delete an instruction."""
headers = {"Authorization": f"Bearer {admin_token}"}
create_resp = await client.post(
"/api/instructions/",
json={"title": "Delete me", "content": "Temp", "type": "global"},
headers=headers,
)
inst_id = create_resp.json()["id"]
resp = await client.delete(f"/api/instructions/{inst_id}", headers=headers)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_non_admin_cannot_manage_instructions(client: AsyncClient, user_token: str):
"""Regular user cannot access instruction endpoints."""
headers = {"Authorization": f"Bearer {user_token}"}
resp = await client.get("/api/instructions/", headers=headers)
assert resp.status_code == 403

View File

@@ -0,0 +1,58 @@
"""FluentGerman.ai — LLM service tests (mocked)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.instruction_service import get_system_prompt
@pytest.mark.asyncio
async def test_system_prompt_empty():
"""When no instructions exist, returns default prompt."""
db = AsyncMock()
# Mock two queries returning empty results
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
db.execute = AsyncMock(return_value=mock_result)
prompt = await get_system_prompt(db, user_id=1)
assert "German" in prompt
assert "tutor" in prompt.lower()
@pytest.mark.asyncio
async def test_system_prompt_with_instructions():
"""System prompt includes global and personal instructions."""
from app.models import Instruction, InstructionType
global_inst = MagicMock()
global_inst.title = "Method"
global_inst.content = "Use immersive conversation"
global_inst.type = InstructionType.GLOBAL
personal_inst = MagicMock()
personal_inst.title = "Focus"
personal_inst.content = "Practice articles"
personal_inst.type = InstructionType.PERSONAL
db = AsyncMock()
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
result = MagicMock()
if call_count == 1:
result.scalars.return_value.all.return_value = [global_inst]
else:
result.scalars.return_value.all.return_value = [personal_inst]
return result
db.execute = mock_execute
prompt = await get_system_prompt(db, user_id=1)
assert "TEACHING METHOD" in prompt
assert "immersive conversation" in prompt
assert "PERSONAL INSTRUCTIONS" in prompt
assert "Practice articles" in prompt

View File

@@ -0,0 +1,95 @@
"""FluentGerman.ai — User management tests."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient, admin_token: str):
"""Admin can create a new client."""
resp = await client.post(
"/api/users/",
json={"username": "alice", "email": "alice@test.com", "password": "pass123"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "alice"
assert data["is_admin"] is False
assert data["is_active"] is True
@pytest.mark.asyncio
async def test_create_duplicate_user(client: AsyncClient, admin_token: str):
"""Duplicate username/email returns 409."""
headers = {"Authorization": f"Bearer {admin_token}"}
await client.post(
"/api/users/",
json={"username": "bob", "email": "bob@test.com", "password": "pass"},
headers=headers,
)
resp = await client.post(
"/api/users/",
json={"username": "bob", "email": "bob2@test.com", "password": "pass"},
headers=headers,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_list_users(client: AsyncClient, admin_token: str):
"""Admin can list all clients."""
headers = {"Authorization": f"Bearer {admin_token}"}
await client.post(
"/api/users/",
json={"username": "charlie", "email": "charlie@test.com", "password": "pass"},
headers=headers,
)
resp = await client.get("/api/users/", headers=headers)
assert resp.status_code == 200
users = resp.json()
assert len(users) >= 1
assert any(u["username"] == "charlie" for u in users)
@pytest.mark.asyncio
async def test_update_user(client: AsyncClient, admin_token: str):
"""Admin can update a client."""
headers = {"Authorization": f"Bearer {admin_token}"}
create_resp = await client.post(
"/api/users/",
json={"username": "dave", "email": "dave@test.com", "password": "pass"},
headers=headers,
)
user_id = create_resp.json()["id"]
resp = await client.put(
f"/api/users/{user_id}",
json={"username": "dave_updated"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["username"] == "dave_updated"
@pytest.mark.asyncio
async def test_delete_user(client: AsyncClient, admin_token: str):
"""Admin can delete a client."""
headers = {"Authorization": f"Bearer {admin_token}"}
create_resp = await client.post(
"/api/users/",
json={"username": "eve", "email": "eve@test.com", "password": "pass"},
headers=headers,
)
user_id = create_resp.json()["id"]
resp = await client.delete(f"/api/users/{user_id}", headers=headers)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_non_admin_cannot_manage_users(client: AsyncClient, user_token: str):
"""Regular user cannot access user management."""
headers = {"Authorization": f"Bearer {user_token}"}
resp = await client.get("/api/users/", headers=headers)
assert resp.status_code == 403

View File

@@ -0,0 +1,18 @@
[Unit]
Description=FluentGerman.ai — Personalized LLM Language Learning
After=network.target postgresql.service
[Service]
Type=simple
User=fluentgerman
Group=fluentgerman
WorkingDirectory=/opt/fluentgerman/backend
Environment="PATH=/opt/fluentgerman/backend/venv/bin"
ExecStart=/opt/fluentgerman/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8999 --workers 2
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

38
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,38 @@
server {
listen 80;
server_name _; # Replace with your domain
# Frontend static files
location / {
root /opt/fluentgerman/frontend;
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE support (for chat streaming)
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# Security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 256;
# Upload size (for voice audio)
client_max_body_size 25M;
}

78
deploy/nginx.conf.example Normal file
View File

@@ -0,0 +1,78 @@
# FluentGerman.ai — Nginx Configuration Template
# Copy to /etc/nginx/sites-available/fluentgerman and adjust values marked with <...>
#
# Setup steps:
# 1. sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/fluentgerman
# 2. Edit the file: replace <YOUR_DOMAIN> and <APP_PORT>
# 3. sudo ln -sf /etc/nginx/sites-available/fluentgerman /etc/nginx/sites-enabled/
# 4. sudo nginx -t && sudo systemctl reload nginx
# 5. Install SSL: sudo certbot --nginx -d <YOUR_DOMAIN>
server {
listen 80;
server_name fluentgerman.mydomain.io; # ← Replace with your subdomain
# Redirect HTTP → HTTPS (uncomment after certbot setup)
# return 301 https://$host$request_uri;
# ── Frontend static files ──────────────────────────────────────
root /opt/fluentgerman/frontend;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# ── API reverse proxy ──────────────────────────────────────────
location /api/ {
proxy_pass http://127.0.0.1:8999; # ← Must match APP_PORT in .env
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# SSE support (critical for chat streaming)
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
chunked_transfer_encoding on;
}
# ── Security headers ───────────────────────────────────────────
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# ── Performance ────────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;
gzip_vary on;
# Upload size limit (voice audio files)
client_max_body_size 25M;
# Static file caching
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
# ── HTTPS block (auto-generated by certbot, shown for reference) ───
# server {
# listen 443 ssl http2;
# server_name fluentgerman.mydomain.io;
#
# ssl_certificate /etc/letsencrypt/live/fluentgerman.mydomain.io/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/fluentgerman.mydomain.io/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
#
# # ... (same location blocks as above)
# }

76
deploy/setup.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
# FluentGerman.ai — Debian deployment setup script
# Run as root or with sudo
set -e
APP_NAME="fluentgerman"
APP_DIR="/opt/$APP_NAME"
APP_USER="fluentgerman"
DB_NAME="fluentgerman"
DB_USER="fluentgerman"
echo "=== FluentGerman.ai — Deployment Setup ==="
# 1. System user
if ! id "$APP_USER" &>/dev/null; then
useradd --system --no-create-home --shell /bin/false "$APP_USER"
echo "✓ Created system user: $APP_USER"
fi
# 2. Install Python if needed
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip > /dev/null
echo "✓ Python installed"
# 3. PostgreSQL database setup
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD 'CHANGE_ME';"
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
echo "✓ PostgreSQL database ready"
# 4. Application directory
mkdir -p "$APP_DIR"
cp -r backend/* "$APP_DIR/backend/"
cp -r frontend/* "$APP_DIR/frontend/"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
echo "✓ Files deployed to $APP_DIR"
# 5. Python virtual environment
cd "$APP_DIR/backend"
python3 -m venv venv
source venv/bin/activate
pip install --quiet -r requirements.txt
deactivate
echo "✓ Python venv created"
# 6. Environment file
if [ ! -f "$APP_DIR/backend/.env" ]; 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"
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/
systemctl daemon-reload
systemctl enable "$APP_NAME"
systemctl start "$APP_NAME"
echo "✓ Systemd service active"
# 8. Nginx config
cp deploy/nginx.conf /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"

182
frontend/admin.html Normal file
View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Admin Dashboard">
<title>FluentGerman.ai — Admin</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<div class="logo">FluentGerman.ai</div>
<div class="navbar-right">
<span class="navbar-user" id="admin-name"></span>
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
</div>
</nav>
<div class="container" style="padding-top: 24px; padding-bottom: 48px;">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-panel="users-panel">Clients</button>
<button class="tab" data-panel="instructions-panel">Instructions</button>
<button class="tab" data-panel="voice-panel">Voice → Instruction</button>
</div>
<!-- Users Panel -->
<div id="users-panel" class="tab-panel">
<div class="flex-between mb-16">
<h2>Client Management</h2>
<button class="btn btn-primary btn-sm" onclick="showUserModal()">+ Add Client</button>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Status</th>
<th class="hide-mobile">Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Instructions Panel -->
<div id="instructions-panel" class="tab-panel hidden">
<div class="flex-between mb-16">
<h2>Instructions</h2>
<div class="flex gap-8">
<button class="btn btn-secondary btn-sm" onclick="uploadInstructionFile()">📁 Upload File</button>
<button class="btn btn-primary btn-sm" onclick="showInstructionModal()">+ Add</button>
</div>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th class="hide-mobile">Client</th>
<th class="hide-mobile">Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="instructions-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Voice Instruction Panel -->
<div id="voice-panel" class="tab-panel hidden">
<h2 class="mb-16">Voice → Instruction Generator</h2>
<p class="text-muted text-sm mb-16">Record your teaching instructions or method using voice, and let AI
structure them into a reusable prompt instruction.</p>
<div class="card">
<div class="form-group">
<label>1. Record or type your raw instruction</label>
<div class="flex gap-8 mb-8">
<button class="btn btn-secondary" id="voice-record-btn" onclick="toggleVoiceRecord()">🎤 Start
Recording</button>
</div>
<textarea id="voice-raw-text" rows="6"
placeholder="Your raw instruction text will appear here after recording, or type directly..."></textarea>
</div>
<button class="btn btn-primary" id="voice-gen-btn" onclick="generateInstruction()">✨ Generate
Instruction</button>
<div id="voice-gen-output" class="hidden mt-24">
<div class="form-group">
<label>2. Review generated instruction</label>
<textarea id="voice-gen-text" rows="8"
placeholder="Generated instruction will appear here..."></textarea>
</div>
<div class="flex gap-8">
<button class="btn btn-primary" onclick="saveGeneratedInstruction()">💾 Save as
Instruction</button>
<button class="btn btn-secondary" onclick="generateInstruction()">🔄 Regenerate</button>
</div>
</div>
</div>
</div>
</div>
<!-- User Modal -->
<div id="user-modal" class="modal-overlay hidden">
<div class="modal">
<h2 id="user-modal-title">Add Client</h2>
<form id="user-form">
<div class="form-group">
<label for="user-username">Username</label>
<input type="text" id="user-username" required>
</div>
<div class="form-group">
<label for="user-email">Email</label>
<input type="email" id="user-email" required>
</div>
<div class="form-group">
<label for="user-password">Password</label>
<input type="password" id="user-password" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Instruction Modal -->
<div id="instruction-modal" class="modal-overlay hidden">
<div class="modal">
<h2 id="instr-modal-title">Add Instruction</h2>
<form id="instruction-form">
<div class="form-group">
<label for="instr-user">Client</label>
<select id="instr-user">
<option value="">Global (all clients)</option>
</select>
</div>
<div class="form-group">
<label for="instr-title">Title</label>
<input type="text" id="instr-title" required>
</div>
<div class="form-group">
<label for="instr-type">Type</label>
<select id="instr-type">
<option value="global">Global Method</option>
<option value="personal">Personal Instruction</option>
<option value="homework">Homework</option>
</select>
</div>
<div class="form-group">
<label for="instr-content">Content</label>
<textarea id="instr-content" rows="8" required></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeInstructionModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/voice.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>

46
frontend/chat.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Chat with your personal German tutor">
<title>FluentGerman.ai — Chat</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<div class="logo">FluentGerman.ai</div>
<div class="navbar-right">
<span class="navbar-user" id="user-name"></span>
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
</div>
</nav>
<!-- Chat -->
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
<div class="message message-assistant">
Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone
to speak.
</div>
</div>
<div class="chat-input-bar">
<button class="voice-btn" id="voice-btn" title="Voice input">🎤</button>
<input type="text" id="chat-input" placeholder="Type your message or click 🎤 to speak..."
autocomplete="off">
<button class="btn btn-primary" id="send-btn">Send</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/voice.js"></script>
<script src="/js/chat.js"></script>
</body>
</html>

942
frontend/css/style.css Normal file
View File

@@ -0,0 +1,942 @@
/* FluentGerman.ai — Design System v2 */
/* ── Fonts ────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* ── CSS Custom Properties ───────────────────────────────────────── */
:root {
/* Gradient palette */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-accent: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-bg: linear-gradient(135deg, #0c0e1a 0%, #141829 50%, #0f1423 100%);
--gradient-card: linear-gradient(145deg, rgba(30, 35, 55, 0.8), rgba(20, 25, 42, 0.6));
--gradient-btn: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-btn-hover: linear-gradient(135deg, #7b93ff 0%, #8a5cb8 100%);
--gradient-warm: linear-gradient(135deg, #f5af19 0%, #f12711 100%);
/* Colors */
--bg-primary: #0c0e1a;
--bg-secondary: #131726;
--bg-card: rgba(22, 27, 45, 0.7);
--bg-input: rgba(35, 42, 65, 0.6);
--bg-hover: rgba(45, 55, 80, 0.5);
--text-primary: #e8ecf5;
--text-secondary: #8b92a8;
--text-muted: #5a6178;
--accent: #7c6cf0;
--accent-hover: #8f82f5;
--accent-glow: rgba(124, 108, 240, 0.3);
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--border: rgba(255, 255, 255, 0.06);
--border-focus: rgba(124, 108, 240, 0.5);
/* Glass */
--glass-bg: rgba(22, 27, 45, 0.65);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 20px;
/* Spacing */
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 30px rgba(124, 108, 240, 0.15);
--shadow-btn-glow: 0 4px 24px rgba(124, 108, 240, 0.35);
/* Transitions */
--transition: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ── Reset & Base ─────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--gradient-bg);
background-attachment: fixed;
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* Animated background orbs */
body::before,
body::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.12;
z-index: -1;
pointer-events: none;
animation: orbFloat 20s ease-in-out infinite alternate;
}
body::before {
width: 600px;
height: 600px;
background: var(--gradient-primary);
top: -200px;
right: -200px;
}
body::after {
width: 500px;
height: 500px;
background: var(--gradient-accent);
bottom: -150px;
left: -150px;
animation-delay: -10s;
}
a {
color: var(--accent);
text-decoration: none;
transition: color var(--transition);
}
a:hover {
color: var(--accent-hover);
}
/* ── Layout ───────────────────────────────────────────────────────── */
.container {
max-width: 940px;
margin: 0 auto;
padding: 0 20px;
}
.page-center {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
/* ── Glass Cards ──────────────────────────────────────────────────── */
.card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-md), var(--shadow-glow);
transition: transform var(--transition), box-shadow var(--transition);
}
.card:hover {
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.card-sm {
max-width: 420px;
width: 100%;
}
/* ── Typography ───────────────────────────────────────────────────── */
h1,
h2,
h3 {
font-weight: 600;
letter-spacing: -0.02em;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.35rem;
}
h3 {
font-size: 1.1rem;
}
.logo {
font-size: 1.6rem;
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.03em;
filter: drop-shadow(0 0 12px rgba(124, 108, 240, 0.3));
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 6px;
}
/* ── Forms ────────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
letter-spacing: 0.02em;
}
input,
select,
textarea {
width: 100%;
padding: 11px 16px;
background: var(--bg-input);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
transition: all var(--transition);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-glow), 0 0 20px rgba(124, 108, 240, 0.1);
background: rgba(40, 48, 75, 0.7);
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}
textarea {
resize: vertical;
min-height: 100px;
}
/* ── Buttons ──────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 22px;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(rgba(255, 255, 255, 0.1), transparent);
opacity: 0;
transition: opacity var(--transition);
}
.btn:hover::before {
opacity: 1;
}
.btn-primary {
background: var(--gradient-btn);
color: #fff;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.25);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-btn-glow);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
}
.btn-secondary {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--glass-border);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.12);
transform: translateY(-1px);
}
.btn-danger {
background: linear-gradient(135deg, #fb7185, #e11d48);
color: #fff;
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(251, 113, 133, 0.3);
}
.btn-sm {
padding: 7px 14px;
font-size: 0.8rem;
border-radius: 8px;
}
.btn-block {
width: 100%;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ── Navbar ───────────────────────────────────────────────────────── */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-user {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 4px;
padding: 4px;
background: rgba(20, 25, 42, 0.6);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin-bottom: 24px;
}
.tab {
flex: 1;
padding: 9px 16px;
background: transparent;
border: none;
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
text-align: center;
position: relative;
}
.tab.active {
background: var(--gradient-btn);
color: #fff;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.3);
}
.tab:hover:not(.active) {
color: var(--text-primary);
background: rgba(124, 108, 240, 0.1);
}
/* ── Chat ─────────────────────────────────────────────────────────── */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 57px);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.message {
max-width: 75%;
padding: 13px 18px;
border-radius: var(--radius-md);
font-size: 0.94rem;
line-height: 1.55;
animation: messageIn 0.4s var(--transition-bounce) backwards;
position: relative;
}
.message-user {
align-self: flex-end;
background: var(--gradient-btn);
color: #fff;
border-bottom-right-radius: 5px;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.2);
}
.message-assistant {
align-self: flex-start;
background: var(--glass-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-bottom-left-radius: 5px;
box-shadow: var(--shadow-sm);
}
/* ── Markdown in messages ──────────────────────────────────────────── */
.message-assistant p {
margin: 0 0 8px 0;
}
.message-assistant p:last-child {
margin-bottom: 0;
}
.message-assistant strong {
color: #c4b5fd;
}
.message-assistant code {
background: rgba(124, 108, 240, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.86em;
}
.message-assistant pre {
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
margin: 8px 0;
}
.message-assistant pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: #d4d4d8;
}
.message-assistant ul,
.message-assistant ol {
margin: 6px 0;
padding-left: 20px;
}
.message-assistant li {
margin-bottom: 3px;
}
.message-assistant blockquote {
border-left: 3px solid var(--accent);
padding: 4px 12px;
margin: 8px 0;
color: var(--text-secondary);
background: rgba(124, 108, 240, 0.05);
border-radius: 0 6px 6px 0;
}
.message-assistant h1,
.message-assistant h2,
.message-assistant h3 {
margin: 10px 0 6px 0;
font-size: 1em;
color: #e0d4ff;
}
.message-assistant hr {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0;
}
.message-assistant table {
margin: 8px 0;
font-size: 0.85em;
}
.message-assistant a {
color: var(--accent-hover);
text-decoration: underline;
text-decoration-style: dotted;
}
.chat-input-bar {
display: flex;
gap: 10px;
padding: 16px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border-top: 1px solid var(--glass-border);
}
.chat-input-bar input {
flex: 1;
}
.voice-btn {
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--bg-input);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.voice-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.05);
}
.voice-btn.recording {
background: linear-gradient(135deg, #fb7185, #e11d48);
color: #fff;
border-color: transparent;
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
animation: recordPulse 1.8s infinite;
}
/* ── Tables ───────────────────────────────────────────────────────── */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
tr {
transition: background var(--transition);
}
tr:hover td {
background: rgba(124, 108, 240, 0.04);
}
/* ── Badge ────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.badge-global {
background: rgba(124, 108, 240, 0.15);
color: #a99bff;
}
.badge-personal {
background: rgba(52, 211, 153, 0.15);
color: #6ee7b7;
}
.badge-homework {
background: rgba(251, 191, 36, 0.15);
color: #fcd34d;
}
/* ── Modal ────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
display: flex;
justify-content: center;
align-items: center;
z-index: 200;
padding: 20px;
animation: fadeIn 0.25s ease;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 30px;
width: 100%;
max-width: 500px;
box-shadow: var(--shadow-lg), 0 0 60px rgba(124, 108, 240, 0.08);
animation: modalIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal h2 {
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
/* ── Toast ────────────────────────────────────────────────────────── */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: var(--shadow-md);
backdrop-filter: blur(12px);
}
.toast-success {
background: linear-gradient(135deg, rgba(52, 211, 153, 0.9), rgba(16, 185, 129, 0.9));
color: #0c2a1c;
}
.toast-error {
background: linear-gradient(135deg, rgba(251, 113, 133, 0.9), rgba(225, 29, 72, 0.9));
color: #fff;
}
/* ── Animations ───────────────────────────────────────────────────── */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(24px) scale(0.9);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes recordPulse {
0% {
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
}
50% {
box-shadow: 0 0 0 14px rgba(251, 113, 133, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0);
}
}
@keyframes orbFloat {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-40px, 30px) scale(1.1);
}
100% {
transform: translate(20px, -20px) scale(0.95);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.card {
padding: 24px;
}
.chat-messages {
padding: 16px;
}
.chat-input-bar {
padding: 12px 16px;
}
.message {
max-width: 88%;
}
.navbar {
padding: 12px 16px;
}
.modal {
padding: 22px;
}
table {
font-size: 0.8rem;
}
th,
td {
padding: 10px 12px;
}
.logo {
font-size: 1.3rem;
}
body::before,
body::after {
display: none;
}
.hide-mobile {
display: none !important;
}
}
/* ── Utilities ────────────────────────────────────────────────────── */
.mt-8 {
margin-top: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-16 {
margin-bottom: 16px;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--text-muted);
}
.text-sm {
font-size: 0.85rem;
}
.flex {
display: flex;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.gap-8 {
gap: 8px;
}
.gap-16 {
gap: 16px;
}
.hidden {
display: none !important;
}
/* ── Scrollbar ────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(124, 108, 240, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 240, 0.4);
}
/* ── Voice status & indicators ────────────────────────────────────── */
.voice-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--danger);
font-weight: 500;
}
.voice-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
animation: recordPulse 1.5s infinite;
}
/* ── Empty state ──────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
/* ── Loading spinner ──────────────────────────────────────────────── */
.spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ── Selection highlight ──────────────────────────────────────────── */
::selection {
background: rgba(124, 108, 240, 0.35);
color: #fff;
}

36
frontend/index.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Your personal AI-powered German language tutor">
<title>FluentGerman.ai — Login</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="page-center">
<div class="card card-sm">
<div class="text-center mb-16">
<div class="logo">FluentGerman.ai</div>
<p class="subtitle">Your personal AI German tutor</p>
</div>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" placeholder="Enter your username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required>
</div>
<div id="login-error" class="text-sm hidden" style="color: var(--danger); margin-bottom: 12px;"></div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
</body>
</html>

341
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,341 @@
/* FluentGerman.ai — Admin panel logic */
document.addEventListener('DOMContentLoaded', () => {
if (!requireAuth() || !requireAdmin()) return;
const user = getUser();
document.getElementById('admin-name').textContent = user?.username || 'Admin';
// Tab switching
const tabs = document.querySelectorAll('.tab');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.add('hidden'));
tab.classList.add('active');
document.getElementById(tab.dataset.panel).classList.remove('hidden');
});
});
// ── Users ──────────────────────────────────────────────────────
const usersBody = document.getElementById('users-body');
const userModal = document.getElementById('user-modal');
const userForm = document.getElementById('user-form');
let editingUserId = null;
async function loadUsers() {
try {
const users = await apiJSON('/users/');
usersBody.innerHTML = '';
if (users.length === 0) {
usersBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No clients yet</td></tr>';
return;
}
users.forEach(u => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${u.username}</td>
<td class="hide-mobile">${u.email}</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>
<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-danger" onclick="deleteUser(${u.id})">✕</button>
</td>
`;
usersBody.appendChild(row);
});
} catch (e) {
showToast(e.message, 'error');
}
}
window.showUserModal = (editing = false) => {
editingUserId = null;
document.getElementById('user-modal-title').textContent = 'Add Client';
userForm.reset();
document.getElementById('user-password').required = true;
userModal.classList.remove('hidden');
};
window.closeUserModal = () => {
userModal.classList.add('hidden');
editingUserId = null;
};
window.editUser = async (id) => {
try {
const users = await apiJSON('/users/');
const u = users.find(x => x.id === id);
if (!u) return;
editingUserId = id;
document.getElementById('user-modal-title').textContent = 'Edit Client';
document.getElementById('user-username').value = u.username;
document.getElementById('user-email').value = u.email;
document.getElementById('user-password').value = '';
document.getElementById('user-password').required = false;
userModal.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
};
userForm.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
username: document.getElementById('user-username').value.trim(),
email: document.getElementById('user-email').value.trim(),
};
const password = document.getElementById('user-password').value;
if (password) data.password = password;
try {
if (editingUserId) {
await apiJSON(`/users/${editingUserId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
showToast('Client updated');
} else {
data.password = password;
await apiJSON('/users/', {
method: 'POST',
body: JSON.stringify(data),
});
showToast('Client created');
}
closeUserModal();
loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
});
window.deleteUser = async (id) => {
if (!confirm('Delete this client? This will also remove all their instructions.')) return;
try {
await api(`/users/${id}`, { method: 'DELETE' });
showToast('Client deleted');
loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
};
// ── Instructions ───────────────────────────────────────────────
const instructionsBody = document.getElementById('instructions-body');
const instrModal = document.getElementById('instruction-modal');
const instrForm = document.getElementById('instruction-form');
const instrUserSelect = document.getElementById('instr-user');
let editingInstrId = null;
let currentFilterUserId = null;
async function loadInstructions(userId = null) {
try {
const url = userId ? `/instructions/?user_id=${userId}` : '/instructions/';
const instructions = await apiJSON(url);
instructionsBody.innerHTML = '';
if (instructions.length === 0) {
instructionsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No instructions yet</td></tr>';
return;
}
instructions.forEach(inst => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${inst.title}</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.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''}</td>
<td>
<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>
</td>
`;
instructionsBody.appendChild(row);
});
} catch (e) {
showToast(e.message, 'error');
}
}
async function loadUserOptions() {
try {
const users = await apiJSON('/users/');
instrUserSelect.innerHTML = '<option value="">Global (all clients)</option>';
users.forEach(u => {
instrUserSelect.innerHTML += `<option value="${u.id}">${u.username}</option>`;
});
} catch (e) {
// silently fail
}
}
window.manageInstructions = (userId, username) => {
currentFilterUserId = userId;
// 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);
};
window.showInstructionModal = () => {
editingInstrId = null;
document.getElementById('instr-modal-title').textContent = 'Add Instruction';
instrForm.reset();
loadUserOptions();
instrModal.classList.remove('hidden');
};
window.closeInstructionModal = () => {
instrModal.classList.add('hidden');
editingInstrId = null;
};
window.editInstruction = async (id) => {
try {
const instructions = await apiJSON('/instructions/');
const inst = instructions.find(x => x.id === id);
if (!inst) return;
editingInstrId = id;
await loadUserOptions();
document.getElementById('instr-modal-title').textContent = 'Edit Instruction';
document.getElementById('instr-title').value = inst.title;
document.getElementById('instr-content').value = inst.content;
document.getElementById('instr-type').value = inst.type;
instrUserSelect.value = inst.user_id || '';
instrModal.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
};
instrForm.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
title: document.getElementById('instr-title').value.trim(),
content: document.getElementById('instr-content').value.trim(),
type: document.getElementById('instr-type').value,
user_id: instrUserSelect.value ? parseInt(instrUserSelect.value) : null,
};
try {
if (editingInstrId) {
await apiJSON(`/instructions/${editingInstrId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
showToast('Instruction updated');
} else {
await apiJSON('/instructions/', {
method: 'POST',
body: JSON.stringify(data),
});
showToast('Instruction created');
}
closeInstructionModal();
loadInstructions(currentFilterUserId);
} catch (e) {
showToast(e.message, 'error');
}
});
window.deleteInstruction = async (id) => {
if (!confirm('Delete this instruction?')) return;
try {
await api(`/instructions/${id}`, { method: 'DELETE' });
showToast('Instruction deleted');
loadInstructions(currentFilterUserId);
} catch (e) {
showToast(e.message, 'error');
}
};
// ── File upload for instructions ────────────────────────────────
window.uploadInstructionFile = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.md';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
document.getElementById('instr-title').value = file.name.replace(/\.[^.]+$/, '');
document.getElementById('instr-content').value = text;
showInstructionModal();
};
input.click();
};
// ── Voice instruction generation ───────────────────────────────
const voiceGenBtn = document.getElementById('voice-gen-btn');
const voiceGenOutput = document.getElementById('voice-gen-output');
const voiceGenText = document.getElementById('voice-gen-text');
const voice = new VoiceManager();
voice.init().then(() => {
voice.onResult = (text) => {
document.getElementById('voice-raw-text').value = text;
};
voice.onStateChange = (recording) => {
const btn = document.getElementById('voice-record-btn');
btn.classList.toggle('recording', recording);
btn.textContent = recording ? '⏹ Stop Recording' : '🎤 Start Recording';
};
});
window.toggleVoiceRecord = () => voice.toggleRecording();
window.generateInstruction = async () => {
const rawText = document.getElementById('voice-raw-text').value.trim();
if (!rawText) {
showToast('Please record or type some text first', 'error');
return;
}
voiceGenBtn.disabled = true;
voiceGenBtn.textContent = 'Generating...';
try {
const result = await apiJSON('/voice/generate-instruction', {
method: 'POST',
body: JSON.stringify({ raw_text: rawText }),
});
voiceGenText.value = result.instruction;
voiceGenOutput.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
voiceGenBtn.disabled = false;
voiceGenBtn.textContent = '✨ Generate Instruction';
};
window.saveGeneratedInstruction = () => {
const content = voiceGenText.value.trim();
if (!content) return;
loadUserOptions();
document.getElementById('instr-content').value = content;
document.getElementById('instr-title').value = 'Voice Generated Instruction';
showInstructionModal();
};
// ── Init ───────────────────────────────────────────────────────
loadUsers();
loadInstructions();
document.getElementById('logout-btn').addEventListener('click', logout);
});

105
frontend/js/api.js Normal file
View File

@@ -0,0 +1,105 @@
/* FluentGerman.ai — API client with auth */
const API_BASE = '/api';
function getToken() {
return localStorage.getItem('fg_token');
}
function setToken(token) {
localStorage.setItem('fg_token', token);
}
function clearToken() {
localStorage.removeItem('fg_token');
}
function getUser() {
const data = localStorage.getItem('fg_user');
return data ? JSON.parse(data) : null;
}
function setUser(user) {
localStorage.setItem('fg_user', JSON.stringify(user));
}
function clearUser() {
localStorage.removeItem('fg_user');
}
async function api(path, options = {}) {
const token = getToken();
const headers = { ...options.headers };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Don't set Content-Type for FormData (browser sets it with boundary)
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (response.status === 401) {
clearToken();
clearUser();
window.location.href = '/';
return;
}
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');
}
return response.json();
}
function requireAuth() {
if (!getToken()) {
window.location.href = '/';
return false;
}
return true;
}
function requireAdmin() {
const user = getUser();
if (!user?.is_admin) {
window.location.href = '/chat.html';
return false;
}
return true;
}
function logout() {
clearToken();
clearUser();
window.location.href = '/';
}
/* Toast notifications */
function showToast(message, type = 'success') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}

56
frontend/js/auth.js Normal file
View File

@@ -0,0 +1,56 @@
/* FluentGerman.ai — Auth page logic */
document.addEventListener('DOMContentLoaded', () => {
// If already logged in, redirect
const token = getToken();
const user = getUser();
if (token && user) {
window.location.href = user.is_admin ? '/admin.html' : '/chat.html';
return;
}
const form = document.getElementById('login-form');
const errorEl = document.getElementById('login-error');
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.classList.add('hidden');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) {
errorEl.textContent = 'Please fill in all fields.';
errorEl.classList.remove('hidden');
return;
}
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Login failed');
}
const data = await response.json();
setToken(data.access_token);
// Fetch user info
const meResp = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${data.access_token}` },
});
const userData = await meResp.json();
setUser(userData);
window.location.href = userData.is_admin ? '/admin.html' : '/chat.html';
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
}
});
});

153
frontend/js/chat.js Normal file
View File

@@ -0,0 +1,153 @@
/* FluentGerman.ai — Chat interface logic */
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub-flavored markdown
});
/**
* Render markdown text to sanitized HTML.
*/
function renderMarkdown(text) {
const raw = marked.parse(text);
return DOMPurify.sanitize(raw);
}
document.addEventListener('DOMContentLoaded', async () => {
if (!requireAuth()) return;
const user = getUser();
document.getElementById('user-name').textContent = user?.username || 'User';
const messagesEl = document.getElementById('chat-messages');
const inputEl = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const voiceBtn = document.getElementById('voice-btn');
let history = [];
// Init voice
const voice = new VoiceManager();
await voice.init();
voice.onResult = (text) => {
inputEl.value = text;
voice.lastInputWasVoice = true;
sendMessage();
};
voice.onStateChange = (recording) => {
voiceBtn.classList.toggle('recording', recording);
voiceBtn.textContent = recording ? '⏹' : '🎤';
};
voiceBtn.addEventListener('click', () => voice.toggleRecording());
// Chat
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = `message message-${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
} else {
div.textContent = content;
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
// Capture whether this was a voice input BEFORE clearing
const wasVoice = voice.lastInputWasVoice;
voice.lastInputWasVoice = false;
inputEl.value = '';
sendBtn.disabled = true;
appendMessage('user', text);
history.push({ role: 'user', content: text });
// Create assistant message placeholder
const assistantEl = appendMessage('assistant', '');
let fullResponse = '';
try {
const response = await api('/chat/', {
method: 'POST',
body: JSON.stringify({ message: text, history: history.slice(-20) }),
});
if (!response?.ok) {
const errData = await response?.json().catch(() => ({}));
throw new Error(errData.detail || `Chat failed (${response?.status})`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
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;
// Live-render markdown as tokens stream in
assistantEl.innerHTML = renderMarkdown(fullResponse);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (parsed.error) {
showToast(parsed.error, 'error');
}
} catch (e) {
// skip unparseable chunks
}
}
}
}
if (fullResponse) {
history.push({ role: 'assistant', content: fullResponse });
// Auto-speak response if the user used voice input
if (wasVoice) {
await voice.speak(fullResponse);
}
}
} catch (e) {
assistantEl.textContent = 'Sorry, something went wrong. Please try again.';
showToast(e.message, 'error');
console.error('[Chat] Error:', e);
}
sendBtn.disabled = false;
inputEl.focus();
}
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Logout
document.getElementById('logout-btn').addEventListener('click', logout);
});

230
frontend/js/voice.js Normal file
View File

@@ -0,0 +1,230 @@
/* FluentGerman.ai — Voice module (Web Speech API + API mode) */
class VoiceManager {
constructor() {
this.mode = 'browser'; // will be set from server config
this.recognition = null;
this.synthesis = window.speechSynthesis;
this.isRecording = false;
this.lastInputWasVoice = false; // tracks if last message was spoken
this.mediaRecorder = null;
this.audioChunks = [];
this.onResult = null;
this.onStateChange = null;
}
async init() {
// Always init browser STT as fallback
this._initBrowserSTT();
// Fetch voice mode from server
try {
const response = await api('/voice/config');
if (response?.ok) {
const config = await response.json();
this.mode = config.voice_mode;
console.log('[Voice] Mode:', this.mode);
}
} catch (e) {
console.warn('[Voice] Could not fetch config, using browser mode');
this.mode = 'browser';
}
}
_initBrowserSTT() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('[Voice] Speech recognition not supported in this browser');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.continuous = false;
this.recognition.interimResults = false;
this.recognition.lang = 'de-DE';
this.recognition.onresult = (event) => {
const text = event.results[0][0].transcript;
console.log('[Voice] Browser STT result:', text);
this.lastInputWasVoice = true;
if (this.onResult) this.onResult(text);
};
this.recognition.onend = () => {
console.log('[Voice] Browser STT ended');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
};
this.recognition.onerror = (event) => {
console.error('[Voice] Browser STT error:', event.error);
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
if (event.error === 'not-allowed') {
showToast('Microphone access denied. Please allow microphone in browser settings.', 'error');
} else if (event.error === 'no-speech') {
showToast('No speech detected. Try again.', 'error');
}
};
console.log('[Voice] Browser STT initialized');
}
async startRecording() {
this.isRecording = true;
this.lastInputWasVoice = true;
if (this.onStateChange) this.onStateChange(true);
if (this.mode === 'api') {
// API mode — record audio via MediaRecorder, send to Whisper
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.audioChunks = [];
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop());
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
await this._transcribeAPI(blob);
};
this.mediaRecorder.start();
console.log('[Voice] API recording started');
} catch (e) {
console.error('[Voice] Microphone access error:', e);
showToast('Microphone access denied', 'error');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
} else {
// Browser mode — use Web Speech API
if (this.recognition) {
try {
this.recognition.start();
console.log('[Voice] Browser STT started');
} catch (e) {
console.error('[Voice] Failed to start recognition:', e);
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
showToast('Voice recognition failed to start. Try again.', 'error');
}
} else {
console.warn('[Voice] No speech recognition available');
showToast('Speech recognition not supported in this browser', 'error');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
}
stopRecording() {
console.log('[Voice] Stopping recording...');
if (this.mode === 'api') {
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
} else {
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
} else {
if (this.recognition) {
try {
this.recognition.stop();
} catch (e) {
// Already stopped
}
}
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
async _transcribeAPI(blob) {
try {
const formData = new FormData();
formData.append('audio', blob, 'recording.webm');
console.log('[Voice] Sending audio to API for transcription...');
const response = await api('/voice/transcribe', {
method: 'POST',
body: formData,
});
if (response?.ok) {
const data = await response.json();
console.log('[Voice] API transcription result:', data.text);
this.lastInputWasVoice = true;
if (this.onResult) this.onResult(data.text);
} else {
showToast('Transcription failed. Falling back to browser voice.', 'error');
// Fallback: switch to browser mode for this session
this.mode = 'browser';
}
} catch (e) {
console.error('[Voice] API transcription error:', e);
showToast('Transcription error', 'error');
} finally {
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
async speak(text) {
if (this.mode === 'api') {
return this._speakAPI(text);
} else {
return this._speakBrowser(text);
}
}
_speakBrowser(text) {
return new Promise((resolve) => {
// Cancel any ongoing speech
this.synthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'de-DE';
utterance.rate = 0.95;
utterance.onend = resolve;
utterance.onerror = () => {
console.warn('[Voice] Browser TTS error');
resolve();
};
this.synthesis.speak(utterance);
});
}
async _speakAPI(text) {
try {
const response = await api(`/voice/synthesize?text=${encodeURIComponent(text)}`, {
method: 'POST',
});
if (response?.ok) {
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
await audio.play();
return new Promise(resolve => {
audio.onended = resolve;
});
}
} catch (e) {
console.warn('[Voice] API TTS failed, falling back to browser');
}
// Fallback to browser TTS
return this._speakBrowser(text);
}
toggleRecording() {
if (this.isRecording) {
this.stopRecording();
} else {
this.startRecording();
}
}
}