From be7bbba45675b4ef6c8cf4053dcf62608412e819 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Thu, 12 Feb 2026 18:45:10 +0100 Subject: [PATCH] initial commit --- .gitea/workflows/deploy.yml | 83 ++ .gitignore | 28 + README.md | 107 +++ backend/.env.example | 33 + backend/app/__init__.py | 0 backend/app/auth.py | 61 ++ backend/app/config.py | 45 + backend/app/database.py | 20 + backend/app/main.py | 98 ++ backend/app/models.py | 52 ++ backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 32 + backend/app/routers/chat.py | 48 + backend/app/routers/instructions.py | 68 ++ backend/app/routers/users.py | 70 ++ backend/app/routers/voice.py | 49 + backend/app/schemas.py | 86 ++ backend/app/services/__init__.py | 0 backend/app/services/instruction_service.py | 52 ++ backend/app/services/llm_service.py | 65 ++ backend/app/services/voice_service.py | 37 + backend/pyproject.toml | 2 + backend/requirements.txt | 19 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 86 ++ backend/tests/test_auth.py | 55 ++ backend/tests/test_instructions.py | 117 +++ backend/tests/test_llm_service.py | 58 ++ backend/tests/test_users.py | 95 ++ deploy/fluentgerman.service | 18 + deploy/nginx.conf | 38 + deploy/nginx.conf.example | 78 ++ deploy/setup.sh | 76 ++ frontend/admin.html | 182 ++++ frontend/chat.html | 46 + frontend/css/style.css | 942 ++++++++++++++++++++ frontend/index.html | 36 + frontend/js/admin.js | 341 +++++++ frontend/js/api.js | 105 +++ frontend/js/auth.js | 56 ++ frontend/js/chat.js | 153 ++++ frontend/js/voice.js | 230 +++++ 42 files changed, 3767 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/chat.py create mode 100644 backend/app/routers/instructions.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/routers/voice.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/instruction_service.py create mode 100644 backend/app/services/llm_service.py create mode 100644 backend/app/services/voice_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_instructions.py create mode 100644 backend/tests/test_llm_service.py create mode 100644 backend/tests/test_users.py create mode 100644 deploy/fluentgerman.service create mode 100644 deploy/nginx.conf create mode 100644 deploy/nginx.conf.example create mode 100644 deploy/setup.sh create mode 100644 frontend/admin.html create mode 100644 frontend/chat.html create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 frontend/js/admin.js create mode 100644 frontend/js/api.js create mode 100644 frontend/js/auth.js create mode 100644 frontend/js/chat.js create mode 100644 frontend/js/voice.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..107d2e7 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98bb43b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef7f00f --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f28a254 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..da336f2 --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..181e595 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..2d56137 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0fbc309 --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..94896c1 --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..67986ae --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000..4883425 --- /dev/null +++ b/backend/app/routers/chat.py @@ -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") diff --git a/backend/app/routers/instructions.py b/backend/app/routers/instructions.py new file mode 100644 index 0000000..2da9857 --- /dev/null +++ b/backend/app/routers/instructions.py @@ -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() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..eeebdae --- /dev/null +++ b/backend/app/routers/users.py @@ -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() diff --git a/backend/app/routers/voice.py b/backend/app/routers/voice.py new file mode 100644 index 0000000..8da4d8c --- /dev/null +++ b/backend/app/routers/voice.py @@ -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} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..4502e06 --- /dev/null +++ b/backend/app/schemas.py @@ -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" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/instruction_service.py b/backend/app/services/instruction_service.py new file mode 100644 index 0000000..34ce66b --- /dev/null +++ b/backend/app/services/instruction_service.py @@ -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) diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py new file mode 100644 index 0000000..9214976 --- /dev/null +++ b/backend/app/services/llm_service.py @@ -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() diff --git a/backend/app/services/voice_service.py b/backend/app/services/voice_service.py new file mode 100644 index 0000000..e555bae --- /dev/null +++ b/backend/app/services/voice_service.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..6eb3df5 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6504e95 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..d217f1d --- /dev/null +++ b/backend/tests/conftest.py @@ -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"] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..75e19cc --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_instructions.py b/backend/tests/test_instructions.py new file mode 100644 index 0000000..37ee6b6 --- /dev/null +++ b/backend/tests/test_instructions.py @@ -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 diff --git a/backend/tests/test_llm_service.py b/backend/tests/test_llm_service.py new file mode 100644 index 0000000..508ef09 --- /dev/null +++ b/backend/tests/test_llm_service.py @@ -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 diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..60518bc --- /dev/null +++ b/backend/tests/test_users.py @@ -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 diff --git a/deploy/fluentgerman.service b/deploy/fluentgerman.service new file mode 100644 index 0000000..74048e8 --- /dev/null +++ b/deploy/fluentgerman.service @@ -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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..fd1ecae --- /dev/null +++ b/deploy/nginx.conf @@ -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; +} diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..7cff458 --- /dev/null +++ b/deploy/nginx.conf.example @@ -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 and +# 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 + +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) +# } diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100644 index 0000000..d37d32d --- /dev/null +++ b/deploy/setup.sh @@ -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" diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..d3ad03d --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,182 @@ + + + + + + + + FluentGerman.ai — Admin + + + + + + + +
+ +
+ + + +
+ + +
+
+

Client Management

+ +
+
+
+ + + + + + + + + + + +
UsernameEmailStatusCreatedActions
+
+
+
+ + + + + + +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/chat.html b/frontend/chat.html new file mode 100644 index 0000000..5b4feb5 --- /dev/null +++ b/frontend/chat.html @@ -0,0 +1,46 @@ + + + + + + + + FluentGerman.ai — Chat + + + + + + + + + + +
+
+
+ Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone + to speak. +
+
+ +
+ + + +
+
+ + + + + + + \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..31dc0d0 --- /dev/null +++ b/frontend/css/style.css @@ -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; +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..08bd679 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,36 @@ + + + + + + + FluentGerman.ai — Login + + + +
+
+
+ +

Your personal AI German tutor

+
+ +
+
+ + +
+
+ + +
+ + +
+
+
+ + + + + diff --git a/frontend/js/admin.js b/frontend/js/admin.js new file mode 100644 index 0000000..092903b --- /dev/null +++ b/frontend/js/admin.js @@ -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 = 'No clients yet'; + return; + } + + users.forEach(u => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${u.username} + ${u.email} + ${u.is_active ? 'Active' : 'Inactive'} + ${new Date(u.created_at).toLocaleDateString()} + + + + + + `; + 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 = 'No instructions yet'; + return; + } + + instructions.forEach(inst => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${inst.title} + ${inst.type} + ${inst.user_id || 'Global'} + ${inst.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''} + + + + + `; + instructionsBody.appendChild(row); + }); + } catch (e) { + showToast(e.message, 'error'); + } + } + + async function loadUserOptions() { + try { + const users = await apiJSON('/users/'); + instrUserSelect.innerHTML = ''; + users.forEach(u => { + instrUserSelect.innerHTML += ``; + }); + } 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); +}); diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..d9180a1 --- /dev/null +++ b/frontend/js/api.js @@ -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); +} diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..1bcfaeb --- /dev/null +++ b/frontend/js/auth.js @@ -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'); + } + }); +}); diff --git a/frontend/js/chat.js b/frontend/js/chat.js new file mode 100644 index 0000000..75699b9 --- /dev/null +++ b/frontend/js/chat.js @@ -0,0 +1,153 @@ +/* FluentGerman.ai — Chat interface logic */ + +// Configure marked for safe rendering +marked.setOptions({ + breaks: true, // Convert \n to
+ 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); +}); diff --git a/frontend/js/voice.js b/frontend/js/voice.js new file mode 100644 index 0000000..33c8ef1 --- /dev/null +++ b/frontend/js/voice.js @@ -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(); + } + } +}