initial commit
This commit is contained in:
83
.gitea/workflows/deploy.yml
Normal file
83
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Deploy FluentGerman.ai
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install aiosqlite
|
||||
python -m pytest tests/ -v --tb=short
|
||||
|
||||
- name: Deploy to server
|
||||
env:
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
# Write SSH credentials
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
|
||||
SSH_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no"
|
||||
|
||||
# Sync backend (excluding .env and venv)
|
||||
rsync -avz --delete \
|
||||
--exclude '.env' \
|
||||
--exclude 'venv/' \
|
||||
--exclude '__pycache__/' \
|
||||
--exclude 'logs/' \
|
||||
--exclude '*.pyc' \
|
||||
-e "ssh $SSH_OPTS" \
|
||||
backend/ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/backend/
|
||||
|
||||
# Sync frontend
|
||||
rsync -avz --delete \
|
||||
-e "ssh $SSH_OPTS" \
|
||||
frontend/ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/frontend/
|
||||
|
||||
# Install deps & restart on server
|
||||
ssh $SSH_OPTS ${DEPLOY_USER}@${DEPLOY_HOST} << REMOTE_SCRIPT
|
||||
set -e
|
||||
cd ${DEPLOY_PATH}/backend
|
||||
|
||||
# Create venv if not exists
|
||||
if [ ! -d "venv" ]; then
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
source venv/bin/activate
|
||||
pip install --quiet -r requirements.txt
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart fluentgerman
|
||||
echo "✓ FluentGerman.ai deployed and restarted"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
# Cleanup
|
||||
rm -f ~/.ssh/deploy_key
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Byte-compiled
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Virtual env
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test artifacts
|
||||
test.db
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# FluentGerman.ai
|
||||
|
||||
**Personalized AI-powered German language learning platform.**
|
||||
|
||||
A web-based tool for a German language teacher to provide clients with customized LLM-powered tutoring through personalized instructions, voice interaction, and a clean admin panel.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **User Management** — Admin creates clients with username, email, password
|
||||
- 📝 **Custom Instructions** — Global teaching method + per-client instructions + homework
|
||||
- 💬 **AI Chat** — Streaming LLM responses with personalized system prompts
|
||||
- 🎤 **Voice Mode** — Speech-to-text & text-to-speech (API or browser fallback)
|
||||
- 🛠 **Admin Panel** — Manage users, upload instruction files, voice-to-instruction generator
|
||||
- 🔄 **Flexible LLM** — Swap OpenAI, Anthropic, or 100+ providers via LiteLLM
|
||||
- 📱 **Mobile Ready** — Responsive design, works on all devices
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.12 + FastAPI |
|
||||
| Database | PostgreSQL (async via SQLAlchemy + asyncpg) |
|
||||
| Auth | JWT + bcrypt |
|
||||
| LLM | LiteLLM (provider-agnostic) |
|
||||
| Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) |
|
||||
| Frontend | Vanilla HTML/CSS/JS |
|
||||
| Deployment | systemd + nginx on Debian |
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
# 1. Clone & setup backend
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Configure
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys, database URL, and admin password
|
||||
|
||||
# 3. Run
|
||||
uvicorn app.main:app --reload
|
||||
# Open http://localhost:8000
|
||||
```
|
||||
|
||||
## Production Deployment (Debian)
|
||||
|
||||
```bash
|
||||
# Prerequisites: PostgreSQL and nginx already installed
|
||||
sudo bash deploy/setup.sh
|
||||
|
||||
# Then edit the .env file:
|
||||
sudo nano /opt/fluentgerman/backend/.env
|
||||
|
||||
# Restart after config changes:
|
||||
sudo systemctl restart fluentgerman
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry point
|
||||
│ │ ├── config.py # Environment-based settings
|
||||
│ │ ├── database.py # Async PostgreSQL setup
|
||||
│ │ ├── models.py # User & Instruction models
|
||||
│ │ ├── schemas.py # Pydantic request/response
|
||||
│ │ ├── auth.py # JWT + bcrypt + dependencies
|
||||
│ │ ├── routers/ # API endpoints
|
||||
│ │ └── services/ # LLM, voice, instruction logic
|
||||
│ ├── tests/ # pytest async tests
|
||||
│ ├── requirements.txt
|
||||
│ └── .env.example
|
||||
├── frontend/
|
||||
│ ├── index.html # Login
|
||||
│ ├── chat.html # Client chat + voice
|
||||
│ ├── admin.html # Admin dashboard
|
||||
│ ├── css/style.css # Design system
|
||||
│ └── js/ # Modules (api, auth, chat, voice, admin)
|
||||
└── deploy/ # systemd, nginx, setup script
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install aiosqlite # needed for test SQLite backend
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings via `.env` file (see `.env.example`):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SECRET_KEY` | JWT signing key (generate a strong random one) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `LLM_API_KEY` | Your LLM provider API key |
|
||||
| `LLM_MODEL` | Model to use (e.g. `gpt-4o-mini`, `claude-3-haiku-20240307`) |
|
||||
| `VOICE_MODE` | `api` (OpenAI Whisper/TTS) or `browser` (Web Speech API) |
|
||||
|
||||
## License
|
||||
|
||||
Private — All rights reserved.
|
||||
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# FluentGerman.ai — Environment Configuration
|
||||
# Copy to .env and fill in your values
|
||||
|
||||
# App
|
||||
APP_PORT=8999
|
||||
|
||||
# Security
|
||||
SECRET_KEY=generate-a-strong-random-key-here
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgresql+asyncpg://fluentgerman:YOUR_PASSWORD@localhost:5432/fluentgerman
|
||||
|
||||
# LLM Provider (via LiteLLM — supports openai, anthropic, gemini, etc.)
|
||||
# For Gemini: set LLM_PROVIDER=gemini, LLM_MODEL=gemini-2.0-flash (auto-prefixed)
|
||||
# For OpenAI: set LLM_PROVIDER=openai, LLM_MODEL=gpt-4o-mini
|
||||
LLM_PROVIDER=gemini
|
||||
LLM_API_KEY=your-api-key-here
|
||||
LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Voice mode: "api" (OpenAI Whisper/TTS) or "browser" (Web Speech API fallback)
|
||||
VOICE_MODE=browser
|
||||
TTS_MODEL=tts-1
|
||||
TTS_VOICE=alloy
|
||||
STT_MODEL=whisper-1
|
||||
|
||||
# Admin bootstrap (only used on first startup)
|
||||
ADMIN_EMAIL=admin@fluentgerman.ai
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-immediately
|
||||
|
||||
# Deployment domain (used in nginx template)
|
||||
# APP_DOMAIN=fluentgerman.mydomain.io
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
61
backend/app/auth.py
Normal file
61
backend/app/auth.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""FluentGerman.ai — Authentication & authorization utilities."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
settings = get_settings()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode = {**data, "exp": expire}
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, get_settings().secret_key, algorithms=[get_settings().algorithm])
|
||||
user_id: int | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exc
|
||||
except JWTError:
|
||||
raise credentials_exc
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exc
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
45
backend/app/config.py
Normal file
45
backend/app/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""FluentGerman.ai — Application configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""App settings loaded from environment / .env file."""
|
||||
|
||||
# App
|
||||
app_name: str = "FluentGerman.ai"
|
||||
debug: bool = False
|
||||
|
||||
# Security
|
||||
secret_key: str = "CHANGE-ME-in-production"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24h
|
||||
algorithm: str = "HS256"
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://fluentgerman:fluentgerman@localhost:5432/fluentgerman"
|
||||
|
||||
# LLM
|
||||
llm_provider: str = "openai" # used by litellm routing
|
||||
llm_api_key: str = ""
|
||||
llm_model: str = "gpt-4o-mini"
|
||||
|
||||
# Voice feature flag: "api" = LLM provider Whisper/TTS, "browser" = Web Speech API
|
||||
voice_mode: Literal["api", "browser"] = "api"
|
||||
tts_model: str = "tts-1"
|
||||
tts_voice: str = "alloy"
|
||||
stt_model: str = "whisper-1"
|
||||
|
||||
# Admin bootstrap
|
||||
admin_email: str = "admin@fluentgerman.ai"
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "CHANGE-ME"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""FluentGerman.ai — Database setup (async PostgreSQL)."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
engine = create_async_engine(get_settings().database_url, echo=get_settings().debug)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
98
backend/app/main.py
Normal file
98
backend/app/main.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""FluentGerman.ai — FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.config import get_settings
|
||||
from app.database import Base, engine, async_session
|
||||
from app.models import User
|
||||
from app.routers import auth, chat, instructions, users, voice
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler("logs/app.log", mode="a"),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("fluentgerman")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Create tables and bootstrap admin user on startup."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Bootstrap admin if not exists
|
||||
settings = get_settings()
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
email=settings.admin_email,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
logger.info("Admin user created: %s", settings.admin_username)
|
||||
|
||||
logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
|
||||
settings.llm_provider, settings.llm_model, settings.voice_mode)
|
||||
yield
|
||||
logger.info("FluentGerman.ai shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FluentGerman.ai",
|
||||
description="Personalized LLM-powered German language learning platform",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Request logging middleware ────────────────────────────────────────
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = round((time.time() - start) * 1000)
|
||||
# Skip logging static file requests to keep logs clean
|
||||
path = request.url.path
|
||||
if path.startswith("/api/"):
|
||||
logger.info("%s %s → %s (%dms)", request.method, path, response.status_code, duration)
|
||||
return response
|
||||
|
||||
|
||||
# CORS — restrict in production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(instructions.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(voice.router)
|
||||
|
||||
# Serve frontend static files
|
||||
app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")
|
||||
52
backend/app/models.py
Normal file
52
backend/app/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""FluentGerman.ai — Database models."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class InstructionType(str, enum.Enum):
|
||||
GLOBAL = "global"
|
||||
PERSONAL = "personal"
|
||||
HOMEWORK = "homework"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
instructions: Mapped[list["Instruction"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Instruction(Base):
|
||||
__tablename__ = "instructions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
type: Mapped[InstructionType] = mapped_column(
|
||||
Enum(InstructionType), default=InstructionType.PERSONAL
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped[User | None] = relationship(back_populates="instructions")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
32
backend/app/routers/auth.py
Normal file
32
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""FluentGerman.ai — Auth router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import create_access_token, get_current_user, verify_password
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, Token, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
48
backend/app/routers/chat.py
Normal file
48
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""FluentGerman.ai — Chat router with SSE streaming."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import ChatRequest
|
||||
from app.services.instruction_service import get_system_prompt
|
||||
from app.services.llm_service import chat_stream
|
||||
|
||||
logger = logging.getLogger("fluentgerman.chat")
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def chat(
|
||||
body: ChatRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a message and receive a streamed SSE response."""
|
||||
logger.info("Chat request from user=%s message_len=%d history=%d",
|
||||
user.username, len(body.message), len(body.history))
|
||||
|
||||
system_prompt = await get_system_prompt(db, user.id)
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.history:
|
||||
messages.append({"role": msg.role, "content": msg.content})
|
||||
messages.append({"role": "user", "content": body.message})
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
async for token in chat_stream(messages):
|
||||
yield f"data: {json.dumps({'token': token})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
logger.error("LLM streaming error: %s", e, exc_info=True)
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
68
backend/app/routers/instructions.py
Normal file
68
backend/app/routers/instructions.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""FluentGerman.ai — Instruction management router (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.database import get_db
|
||||
from app.models import Instruction, InstructionType
|
||||
from app.schemas import InstructionCreate, InstructionOut, InstructionUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/instructions", tags=["instructions"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InstructionOut])
|
||||
async def list_instructions(user_id: int | None = None, db: AsyncSession = Depends(get_db)):
|
||||
query = select(Instruction).order_by(Instruction.created_at.desc())
|
||||
if user_id is not None:
|
||||
# Fetch per-user + global instructions
|
||||
query = query.where((Instruction.user_id == user_id) | Instruction.user_id.is_(None))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=InstructionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_instruction(body: InstructionCreate, db: AsyncSession = Depends(get_db)):
|
||||
instruction = Instruction(
|
||||
user_id=body.user_id,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
type=InstructionType(body.type),
|
||||
)
|
||||
db.add(instruction)
|
||||
await db.commit()
|
||||
await db.refresh(instruction)
|
||||
return instruction
|
||||
|
||||
|
||||
@router.put("/{instruction_id}", response_model=InstructionOut)
|
||||
async def update_instruction(
|
||||
instruction_id: int, body: InstructionUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
|
||||
inst = result.scalar_one_or_none()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
|
||||
|
||||
if body.title is not None:
|
||||
inst.title = body.title
|
||||
if body.content is not None:
|
||||
inst.content = body.content
|
||||
if body.type is not None:
|
||||
inst.type = InstructionType(body.type)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(inst)
|
||||
return inst
|
||||
|
||||
|
||||
@router.delete("/{instruction_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_instruction(instruction_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Instruction).where(Instruction.id == instruction_id))
|
||||
inst = result.scalar_one_or_none()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Instruction not found")
|
||||
|
||||
await db.delete(inst)
|
||||
await db.commit()
|
||||
70
backend/app/routers/users.py
Normal file
70
backend/app/routers/users.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""FluentGerman.ai — User management router (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserOut, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserOut])
|
||||
async def list_users(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.is_admin == False).order_by(User.created_at.desc())) # noqa: E712
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
# Check uniqueness
|
||||
existing = await db.execute(
|
||||
select(User).where((User.username == body.username) | (User.email == body.email))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserOut)
|
||||
async def update_user(user_id: int, body: UserUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if body.username is not None:
|
||||
user.username = body.username
|
||||
if body.email is not None:
|
||||
user.email = body.email
|
||||
if body.password is not None:
|
||||
user.hashed_password = hash_password(body.password)
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
49
backend/app/routers/voice.py
Normal file
49
backend/app/routers/voice.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""FluentGerman.ai — Admin voice-to-instruction & voice API router."""
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_admin, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
|
||||
from app.services.llm_service import summarize_instruction
|
||||
from app.services.voice_service import synthesize, transcribe
|
||||
from fastapi.responses import Response
|
||||
|
||||
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=VoiceConfigOut)
|
||||
async def voice_config(user: User = Depends(get_current_user)):
|
||||
"""Return current voice mode so frontend knows whether to use browser or API."""
|
||||
return VoiceConfigOut(voice_mode=get_settings().voice_mode)
|
||||
|
||||
|
||||
@router.post("/transcribe")
|
||||
async def transcribe_audio(
|
||||
audio: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Transcribe uploaded audio to text (API mode only)."""
|
||||
audio_bytes = await audio.read()
|
||||
text = await transcribe(audio_bytes, filename=audio.filename or "audio.webm")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
@router.post("/synthesize")
|
||||
async def synthesize_text(
|
||||
text: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech audio (API mode only)."""
|
||||
audio_bytes = await synthesize(text)
|
||||
return Response(content=audio_bytes, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@router.post("/generate-instruction", dependencies=[Depends(require_admin)])
|
||||
async def generate_instruction(body: VoiceInstructionRequest):
|
||||
"""Admin: takes raw transcript and returns a structured instruction via LLM."""
|
||||
structured = await summarize_instruction(body.raw_text)
|
||||
return {"instruction": structured}
|
||||
86
backend/app/schemas.py
Normal file
86
backend/app/schemas.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""FluentGerman.ai — Pydantic request/response schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
# ── User ──────────────────────────────────────────────────────────────
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
email: EmailStr | None = None
|
||||
password: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Instruction ───────────────────────────────────────────────────────
|
||||
class InstructionCreate(BaseModel):
|
||||
user_id: int | None = None
|
||||
title: str
|
||||
content: str
|
||||
type: str = "personal" # global | personal | homework
|
||||
|
||||
|
||||
class InstructionUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class InstructionOut(BaseModel):
|
||||
id: int
|
||||
user_id: int | None
|
||||
title: str
|
||||
content: str
|
||||
type: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Chat ──────────────────────────────────────────────────────────────
|
||||
class ChatMessage(BaseModel):
|
||||
role: str # "user" | "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
history: list[ChatMessage] = []
|
||||
|
||||
|
||||
# ── Voice / Admin ────────────────────────────────────────────────────
|
||||
class VoiceInstructionRequest(BaseModel):
|
||||
raw_text: str
|
||||
|
||||
|
||||
class VoiceConfigOut(BaseModel):
|
||||
voice_mode: str # "api" | "browser"
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
52
backend/app/services/instruction_service.py
Normal file
52
backend/app/services/instruction_service.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""FluentGerman.ai — Instruction assembly service."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Instruction, InstructionType
|
||||
|
||||
|
||||
async def get_system_prompt(db: AsyncSession, user_id: int) -> str:
|
||||
"""Assemble the full system prompt from global + personal + homework instructions."""
|
||||
|
||||
# Global instructions (no user_id)
|
||||
result = await db.execute(
|
||||
select(Instruction).where(
|
||||
Instruction.user_id.is_(None),
|
||||
Instruction.type == InstructionType.GLOBAL,
|
||||
)
|
||||
)
|
||||
global_instructions = result.scalars().all()
|
||||
|
||||
# Personal + homework for this user
|
||||
result = await db.execute(
|
||||
select(Instruction).where(Instruction.user_id == user_id)
|
||||
)
|
||||
user_instructions = result.scalars().all()
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
if global_instructions:
|
||||
parts.append("=== TEACHING METHOD ===")
|
||||
for inst in global_instructions:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
personal = [i for i in user_instructions if i.type == InstructionType.PERSONAL]
|
||||
if personal:
|
||||
parts.append("\n=== PERSONAL INSTRUCTIONS ===")
|
||||
for inst in personal:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
homework = [i for i in user_instructions if i.type == InstructionType.HOMEWORK]
|
||||
if homework:
|
||||
parts.append("\n=== CURRENT HOMEWORK ===")
|
||||
for inst in homework:
|
||||
parts.append(f"[{inst.title}]\n{inst.content}")
|
||||
|
||||
if not parts:
|
||||
return (
|
||||
"You are a helpful German language tutor. Help the student learn German "
|
||||
"through conversation, corrections, and explanations."
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
65
backend/app/services/llm_service.py
Normal file
65
backend/app/services/llm_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""FluentGerman.ai — LLM service (provider-agnostic via LiteLLM)."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import litellm
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("fluentgerman.llm")
|
||||
|
||||
|
||||
def _resolve_model(model: str, provider: str) -> str:
|
||||
"""Ensure Gemini models have the 'gemini/' prefix for Google AI Studio."""
|
||||
if provider == "gemini" and not model.startswith("gemini/"):
|
||||
resolved = f"gemini/{model}"
|
||||
logger.info("Auto-prefixed model: %s → %s (Google AI Studio)", model, resolved)
|
||||
return resolved
|
||||
return model
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completion tokens from the configured LLM provider."""
|
||||
settings = get_settings()
|
||||
model = _resolve_model(model or settings.llm_model, settings.llm_provider)
|
||||
|
||||
logger.info("LLM request: model=%s messages=%d", model, len(messages))
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
api_key=settings.llm_api_key,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
|
||||
async def summarize_instruction(raw_text: str) -> str:
|
||||
"""Ask the LLM to distill raw voice transcript into a structured instruction."""
|
||||
settings = get_settings()
|
||||
model = _resolve_model(settings.llm_model, settings.llm_provider)
|
||||
|
||||
meta_prompt = (
|
||||
"You are an expert assistant for a language teacher. "
|
||||
"The following text is a raw transcript of the teacher describing a learning instruction, "
|
||||
"homework, or teaching method. Distill it into a clear, concise, structured instruction "
|
||||
"that can be used as a system prompt for a language-learning LLM assistant. "
|
||||
"Output ONLY the instruction text, no preamble.\n\n"
|
||||
f"Transcript:\n{raw_text}"
|
||||
)
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": meta_prompt}],
|
||||
api_key=settings.llm_api_key,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
37
backend/app/services/voice_service.py
Normal file
37
backend/app/services/voice_service.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""FluentGerman.ai — Voice service (API provider + browser fallback)."""
|
||||
|
||||
import io
|
||||
|
||||
import openai
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def transcribe(audio_bytes: bytes, filename: str = "audio.webm") -> str:
|
||||
"""Transcribe audio to text using OpenAI Whisper API."""
|
||||
settings = get_settings()
|
||||
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
|
||||
|
||||
audio_file = io.BytesIO(audio_bytes)
|
||||
audio_file.name = filename
|
||||
|
||||
transcript = await client.audio.transcriptions.create(
|
||||
model=settings.stt_model,
|
||||
file=audio_file,
|
||||
)
|
||||
return transcript.text
|
||||
|
||||
|
||||
async def synthesize(text: str) -> bytes:
|
||||
"""Synthesize text to speech using OpenAI TTS API."""
|
||||
settings = get_settings()
|
||||
client = openai.AsyncOpenAI(api_key=settings.llm_api_key)
|
||||
|
||||
response = await client.audio.speech.create(
|
||||
model=settings.tts_model,
|
||||
voice=settings.tts_voice,
|
||||
input=text,
|
||||
response_format="mp3",
|
||||
)
|
||||
|
||||
return response.content
|
||||
2
backend/pyproject.toml
Normal file
2
backend/pyproject.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
19
backend/requirements.txt
Normal file
19
backend/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# FluentGerman.ai — Python dependencies
|
||||
fastapi[standard]>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
pydantic[email]>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
bcrypt>=4.0.0
|
||||
litellm>=1.50.0
|
||||
openai>=1.50.0
|
||||
python-multipart>=0.0.9
|
||||
httpx>=0.27.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
aiosqlite>=0.20.0
|
||||
httpx>=0.27.0
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
86
backend/tests/conftest.py
Normal file
86
backend/tests/conftest.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""FluentGerman.ai — Test configuration & fixtures."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
# Use SQLite for tests (in-memory)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
|
||||
def get_test_settings() -> Settings:
|
||||
return Settings(
|
||||
database_url=TEST_DATABASE_URL,
|
||||
secret_key="test-secret-key",
|
||||
llm_api_key="test-key",
|
||||
admin_password="testadmin123",
|
||||
)
|
||||
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with test_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_settings] = get_test_settings
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(client: AsyncClient) -> str:
|
||||
"""Create admin and return token."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
|
||||
async with test_session() as db:
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@test.com",
|
||||
hashed_password=hash_password("admin123"),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_token(client: AsyncClient, admin_token: str) -> str:
|
||||
"""Create a regular user via admin API and return their token."""
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "testuser", "email": "user@test.com", "password": "user123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
resp = await client.post("/api/auth/login", json={"username": "testuser", "password": "user123"})
|
||||
return resp.json()["access_token"]
|
||||
55
backend/tests/test_auth.py
Normal file
55
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""FluentGerman.ai — Auth tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient, admin_token: str):
|
||||
"""Admin can log in and receives a token."""
|
||||
assert admin_token is not None
|
||||
assert len(admin_token) > 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
"""Wrong password returns 401."""
|
||||
from app.auth import hash_password
|
||||
from app.models import User
|
||||
from tests.conftest import test_session
|
||||
|
||||
async with test_session() as db:
|
||||
user = User(
|
||||
username="logintest",
|
||||
email="logintest@test.com",
|
||||
hashed_password=hash_password("correct"),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/auth/login", json={"username": "logintest", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(client: AsyncClient):
|
||||
"""Nonexistent user returns 401."""
|
||||
resp = await client.post("/api/auth/login", json={"username": "nobody", "password": "pass"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client: AsyncClient, admin_token: str):
|
||||
"""Authenticated user can access /me."""
|
||||
resp = await client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_unauthenticated(client: AsyncClient):
|
||||
"""Unauthenticated request to /me returns 401."""
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
117
backend/tests/test_instructions.py
Normal file
117
backend/tests/test_instructions.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""FluentGerman.ai — Instruction management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_global_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a global instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Teaching Method", "content": "Use immersive conversation", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "Teaching Method"
|
||||
assert data["type"] == "global"
|
||||
assert data["user_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_personal_instruction(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Admin can create a personal instruction for a user."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Get user id
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={
|
||||
"title": "Focus on verbs",
|
||||
"content": "This student needs extra practice with irregular verbs.",
|
||||
"type": "personal",
|
||||
"user_id": user_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["user_id"] == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_instructions_by_user(client: AsyncClient, admin_token: str, user_token: str):
|
||||
"""Filtering instructions by user returns personal + global."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
users_resp = await client.get("/api/users/", headers=headers)
|
||||
user_id = users_resp.json()[0]["id"]
|
||||
|
||||
# Create global
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Global Rule", "content": "Always respond in German", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
# Create personal
|
||||
await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Personal", "content": "Focus on A1", "type": "personal", "user_id": user_id},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/instructions/?user_id={user_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
instructions = resp.json()
|
||||
types = [i["type"] for i in instructions]
|
||||
assert "global" in types
|
||||
assert "personal" in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Old Title", "content": "Old content", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/instructions/{inst_id}",
|
||||
json={"title": "New Title", "content": "Updated content"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "New Title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_instruction(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete an instruction."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/instructions/",
|
||||
json={"title": "Delete me", "content": "Temp", "type": "global"},
|
||||
headers=headers,
|
||||
)
|
||||
inst_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/instructions/{inst_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_instructions(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access instruction endpoints."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/instructions/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
58
backend/tests/test_llm_service.py
Normal file
58
backend/tests/test_llm_service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""FluentGerman.ai — LLM service tests (mocked)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.instruction_service import get_system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_empty():
|
||||
"""When no instructions exist, returns default prompt."""
|
||||
db = AsyncMock()
|
||||
# Mock two queries returning empty results
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "German" in prompt
|
||||
assert "tutor" in prompt.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_with_instructions():
|
||||
"""System prompt includes global and personal instructions."""
|
||||
from app.models import Instruction, InstructionType
|
||||
|
||||
global_inst = MagicMock()
|
||||
global_inst.title = "Method"
|
||||
global_inst.content = "Use immersive conversation"
|
||||
global_inst.type = InstructionType.GLOBAL
|
||||
|
||||
personal_inst = MagicMock()
|
||||
personal_inst.title = "Focus"
|
||||
personal_inst.content = "Practice articles"
|
||||
personal_inst.type = InstructionType.PERSONAL
|
||||
|
||||
db = AsyncMock()
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = MagicMock()
|
||||
if call_count == 1:
|
||||
result.scalars.return_value.all.return_value = [global_inst]
|
||||
else:
|
||||
result.scalars.return_value.all.return_value = [personal_inst]
|
||||
return result
|
||||
|
||||
db.execute = mock_execute
|
||||
|
||||
prompt = await get_system_prompt(db, user_id=1)
|
||||
assert "TEACHING METHOD" in prompt
|
||||
assert "immersive conversation" in prompt
|
||||
assert "PERSONAL INSTRUCTIONS" in prompt
|
||||
assert "Practice articles" in prompt
|
||||
95
backend/tests/test_users.py
Normal file
95
backend/tests/test_users.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""FluentGerman.ai — User management tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can create a new client."""
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "alice", "email": "alice@test.com", "password": "pass123"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["username"] == "alice"
|
||||
assert data["is_admin"] is False
|
||||
assert data["is_active"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_user(client: AsyncClient, admin_token: str):
|
||||
"""Duplicate username/email returns 409."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "bob", "email": "bob2@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client: AsyncClient, admin_token: str):
|
||||
"""Admin can list all clients."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "charlie", "email": "charlie@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
assert len(users) >= 1
|
||||
assert any(u["username"] == "charlie" for u in users)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can update a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "dave", "email": "dave@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/users/{user_id}",
|
||||
json={"username": "dave_updated"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "dave_updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(client: AsyncClient, admin_token: str):
|
||||
"""Admin can delete a client."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
create_resp = await client.post(
|
||||
"/api/users/",
|
||||
json={"username": "eve", "email": "eve@test.com", "password": "pass"},
|
||||
headers=headers,
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/users/{user_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_manage_users(client: AsyncClient, user_token: str):
|
||||
"""Regular user cannot access user management."""
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
resp = await client.get("/api/users/", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
18
deploy/fluentgerman.service
Normal file
18
deploy/fluentgerman.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=FluentGerman.ai — Personalized LLM Language Learning
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=fluentgerman
|
||||
Group=fluentgerman
|
||||
WorkingDirectory=/opt/fluentgerman/backend
|
||||
Environment="PATH=/opt/fluentgerman/backend/venv/bin"
|
||||
ExecStart=/opt/fluentgerman/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8999 --workers 2
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
38
deploy/nginx.conf
Normal file
38
deploy/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _; # Replace with your domain
|
||||
|
||||
# Frontend static files
|
||||
location / {
|
||||
root /opt/fluentgerman/frontend;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE support (for chat streaming)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
gzip_min_length 256;
|
||||
|
||||
# Upload size (for voice audio)
|
||||
client_max_body_size 25M;
|
||||
}
|
||||
78
deploy/nginx.conf.example
Normal file
78
deploy/nginx.conf.example
Normal file
@@ -0,0 +1,78 @@
|
||||
# FluentGerman.ai — Nginx Configuration Template
|
||||
# Copy to /etc/nginx/sites-available/fluentgerman and adjust values marked with <...>
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/fluentgerman
|
||||
# 2. Edit the file: replace <YOUR_DOMAIN> and <APP_PORT>
|
||||
# 3. sudo ln -sf /etc/nginx/sites-available/fluentgerman /etc/nginx/sites-enabled/
|
||||
# 4. sudo nginx -t && sudo systemctl reload nginx
|
||||
# 5. Install SSL: sudo certbot --nginx -d <YOUR_DOMAIN>
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name fluentgerman.mydomain.io; # ← Replace with your subdomain
|
||||
|
||||
# Redirect HTTP → HTTPS (uncomment after certbot setup)
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
# ── Frontend static files ──────────────────────────────────────
|
||||
root /opt/fluentgerman/frontend;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ── API reverse proxy ──────────────────────────────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8999; # ← Must match APP_PORT in .env
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection '';
|
||||
|
||||
# SSE support (critical for chat streaming)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
chunked_transfer_encoding on;
|
||||
}
|
||||
|
||||
# ── Security headers ───────────────────────────────────────────
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# ── Performance ────────────────────────────────────────────────
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
|
||||
# Upload size limit (voice audio files)
|
||||
client_max_body_size 25M;
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# ── HTTPS block (auto-generated by certbot, shown for reference) ───
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name fluentgerman.mydomain.io;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/fluentgerman.mydomain.io/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/fluentgerman.mydomain.io/privkey.pem;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
#
|
||||
# # ... (same location blocks as above)
|
||||
# }
|
||||
76
deploy/setup.sh
Normal file
76
deploy/setup.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# FluentGerman.ai — Debian deployment setup script
|
||||
# Run as root or with sudo
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="fluentgerman"
|
||||
APP_DIR="/opt/$APP_NAME"
|
||||
APP_USER="fluentgerman"
|
||||
DB_NAME="fluentgerman"
|
||||
DB_USER="fluentgerman"
|
||||
|
||||
echo "=== FluentGerman.ai — Deployment Setup ==="
|
||||
|
||||
# 1. System user
|
||||
if ! id "$APP_USER" &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /bin/false "$APP_USER"
|
||||
echo "✓ Created system user: $APP_USER"
|
||||
fi
|
||||
|
||||
# 2. Install Python if needed
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-venv python3-pip > /dev/null
|
||||
echo "✓ Python installed"
|
||||
|
||||
# 3. PostgreSQL database setup
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD 'CHANGE_ME';"
|
||||
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||
|
||||
echo "✓ PostgreSQL database ready"
|
||||
|
||||
# 4. Application directory
|
||||
mkdir -p "$APP_DIR"
|
||||
cp -r backend/* "$APP_DIR/backend/"
|
||||
cp -r frontend/* "$APP_DIR/frontend/"
|
||||
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
|
||||
echo "✓ Files deployed to $APP_DIR"
|
||||
|
||||
# 5. Python virtual environment
|
||||
cd "$APP_DIR/backend"
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --quiet -r requirements.txt
|
||||
deactivate
|
||||
echo "✓ Python venv created"
|
||||
|
||||
# 6. Environment file
|
||||
if [ ! -f "$APP_DIR/backend/.env" ]; then
|
||||
cp "$APP_DIR/backend/.env.example" "$APP_DIR/backend/.env"
|
||||
# Generate random secret key
|
||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||
sed -i "s/generate-a-strong-random-key-here/$SECRET/" "$APP_DIR/backend/.env"
|
||||
echo "⚠ Created .env from template — EDIT $APP_DIR/backend/.env with your API keys and passwords!"
|
||||
fi
|
||||
|
||||
# 7. Systemd service
|
||||
cp deploy/fluentgerman.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$APP_NAME"
|
||||
systemctl start "$APP_NAME"
|
||||
echo "✓ Systemd service active"
|
||||
|
||||
# 8. Nginx config
|
||||
cp deploy/nginx.conf /etc/nginx/sites-available/$APP_NAME
|
||||
ln -sf /etc/nginx/sites-available/$APP_NAME /etc/nginx/sites-enabled/
|
||||
nginx -t && systemctl reload nginx
|
||||
echo "✓ Nginx configured"
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment complete! ==="
|
||||
echo "1. Edit /opt/$APP_NAME/backend/.env with your settings"
|
||||
echo "2. Restart: systemctl restart $APP_NAME"
|
||||
echo "3. Access: http://your-server-domain"
|
||||
182
frontend/admin.html
Normal file
182
frontend/admin.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="FluentGerman.ai — Admin Dashboard">
|
||||
<title>FluentGerman.ai — Admin</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar">
|
||||
<div class="logo">FluentGerman.ai</div>
|
||||
<div class="navbar-right">
|
||||
<span class="navbar-user" id="admin-name"></span>
|
||||
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container" style="padding-top: 24px; padding-bottom: 48px;">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-panel="users-panel">Clients</button>
|
||||
<button class="tab" data-panel="instructions-panel">Instructions</button>
|
||||
<button class="tab" data-panel="voice-panel">Voice → Instruction</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Panel -->
|
||||
<div id="users-panel" class="tab-panel">
|
||||
<div class="flex-between mb-16">
|
||||
<h2>Client Management</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showUserModal()">+ Add Client</button>
|
||||
</div>
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th class="hide-mobile">Email</th>
|
||||
<th>Status</th>
|
||||
<th class="hide-mobile">Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Panel -->
|
||||
<div id="instructions-panel" class="tab-panel hidden">
|
||||
<div class="flex-between mb-16">
|
||||
<h2>Instructions</h2>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-secondary btn-sm" onclick="uploadInstructionFile()">📁 Upload File</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="showInstructionModal()">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th class="hide-mobile">Client</th>
|
||||
<th class="hide-mobile">Preview</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="instructions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Instruction Panel -->
|
||||
<div id="voice-panel" class="tab-panel hidden">
|
||||
<h2 class="mb-16">Voice → Instruction Generator</h2>
|
||||
<p class="text-muted text-sm mb-16">Record your teaching instructions or method using voice, and let AI
|
||||
structure them into a reusable prompt instruction.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label>1. Record or type your raw instruction</label>
|
||||
<div class="flex gap-8 mb-8">
|
||||
<button class="btn btn-secondary" id="voice-record-btn" onclick="toggleVoiceRecord()">🎤 Start
|
||||
Recording</button>
|
||||
</div>
|
||||
<textarea id="voice-raw-text" rows="6"
|
||||
placeholder="Your raw instruction text will appear here after recording, or type directly..."></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="voice-gen-btn" onclick="generateInstruction()">✨ Generate
|
||||
Instruction</button>
|
||||
|
||||
<div id="voice-gen-output" class="hidden mt-24">
|
||||
<div class="form-group">
|
||||
<label>2. Review generated instruction</label>
|
||||
<textarea id="voice-gen-text" rows="8"
|
||||
placeholder="Generated instruction will appear here..."></textarea>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-primary" onclick="saveGeneratedInstruction()">💾 Save as
|
||||
Instruction</button>
|
||||
<button class="btn btn-secondary" onclick="generateInstruction()">🔄 Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Modal -->
|
||||
<div id="user-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<h2 id="user-modal-title">Add Client</h2>
|
||||
<form id="user-form">
|
||||
<div class="form-group">
|
||||
<label for="user-username">Username</label>
|
||||
<input type="text" id="user-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="user-email">Email</label>
|
||||
<input type="email" id="user-email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="user-password">Password</label>
|
||||
<input type="password" id="user-password" required>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instruction Modal -->
|
||||
<div id="instruction-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<h2 id="instr-modal-title">Add Instruction</h2>
|
||||
<form id="instruction-form">
|
||||
<div class="form-group">
|
||||
<label for="instr-user">Client</label>
|
||||
<select id="instr-user">
|
||||
<option value="">Global (all clients)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="instr-title">Title</label>
|
||||
<input type="text" id="instr-title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="instr-type">Type</label>
|
||||
<select id="instr-type">
|
||||
<option value="global">Global Method</option>
|
||||
<option value="personal">Personal Instruction</option>
|
||||
<option value="homework">Homework</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="instr-content">Content</label>
|
||||
<textarea id="instr-content" rows="8" required></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeInstructionModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/voice.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
46
frontend/chat.html
Normal file
46
frontend/chat.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="FluentGerman.ai — Chat with your personal German tutor">
|
||||
<title>FluentGerman.ai — Chat</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar">
|
||||
<div class="logo">FluentGerman.ai</div>
|
||||
<div class="navbar-right">
|
||||
<span class="navbar-user" id="user-name"></span>
|
||||
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Chat -->
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="message message-assistant">
|
||||
Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone
|
||||
to speak.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<button class="voice-btn" id="voice-btn" title="Voice input">🎤</button>
|
||||
<input type="text" id="chat-input" placeholder="Type your message or click 🎤 to speak..."
|
||||
autocomplete="off">
|
||||
<button class="btn btn-primary" id="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/voice.js"></script>
|
||||
<script src="/js/chat.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
942
frontend/css/style.css
Normal file
942
frontend/css/style.css
Normal file
@@ -0,0 +1,942 @@
|
||||
/* FluentGerman.ai — Design System v2 */
|
||||
|
||||
/* ── Fonts ────────────────────────────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* ── CSS Custom Properties ───────────────────────────────────────── */
|
||||
:root {
|
||||
/* Gradient palette */
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-bg: linear-gradient(135deg, #0c0e1a 0%, #141829 50%, #0f1423 100%);
|
||||
--gradient-card: linear-gradient(145deg, rgba(30, 35, 55, 0.8), rgba(20, 25, 42, 0.6));
|
||||
--gradient-btn: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-btn-hover: linear-gradient(135deg, #7b93ff 0%, #8a5cb8 100%);
|
||||
--gradient-warm: linear-gradient(135deg, #f5af19 0%, #f12711 100%);
|
||||
|
||||
/* Colors */
|
||||
--bg-primary: #0c0e1a;
|
||||
--bg-secondary: #131726;
|
||||
--bg-card: rgba(22, 27, 45, 0.7);
|
||||
--bg-input: rgba(35, 42, 65, 0.6);
|
||||
--bg-hover: rgba(45, 55, 80, 0.5);
|
||||
--text-primary: #e8ecf5;
|
||||
--text-secondary: #8b92a8;
|
||||
--text-muted: #5a6178;
|
||||
--accent: #7c6cf0;
|
||||
--accent-hover: #8f82f5;
|
||||
--accent-glow: rgba(124, 108, 240, 0.3);
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-focus: rgba(124, 108, 240, 0.5);
|
||||
|
||||
/* Glass */
|
||||
--glass-bg: rgba(22, 27, 45, 0.65);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-blur: 20px;
|
||||
|
||||
/* Spacing */
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 30px rgba(124, 108, 240, 0.15);
|
||||
--shadow-btn-glow: 0 4px 24px rgba(124, 108, 240, 0.35);
|
||||
|
||||
/* Transitions */
|
||||
--transition: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--gradient-bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated background orbs */
|
||||
body::before,
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.12;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
animation: orbFloat 20s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
body::before {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: var(--gradient-primary);
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
}
|
||||
|
||||
body::after {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: var(--gradient-accent);
|
||||
bottom: -150px;
|
||||
left: -150px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────── */
|
||||
.container {
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.page-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ── Glass Cards ──────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||
transition: transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.card-sm {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Typography ───────────────────────────────────────────────────── */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.03em;
|
||||
filter: drop-shadow(0 0 12px rgba(124, 108, 240, 0.3));
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
background: var(--bg-input);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow), 0 0 20px rgba(124, 108, 240, 0.1);
|
||||
background: rgba(40, 48, 75, 0.7);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 11px 22px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.1), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.btn:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-btn);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-btn-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #fb7185, #e11d48);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(251, 113, 133, 0.3);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 7px 14px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Navbar ───────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Tabs ─────────────────────────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: rgba(20, 25, 42, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 9px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--gradient-btn);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.3);
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(124, 108, 240, 0.1);
|
||||
}
|
||||
|
||||
/* ── Chat ─────────────────────────────────────────────────────────── */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 75%;
|
||||
padding: 13px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.55;
|
||||
animation: messageIn 0.4s var(--transition-bounce) backwards;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
align-self: flex-end;
|
||||
background: var(--gradient-btn);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 5px;
|
||||
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.2);
|
||||
}
|
||||
|
||||
.message-assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom-left-radius: 5px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ── Markdown in messages ──────────────────────────────────────────── */
|
||||
.message-assistant p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.message-assistant p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-assistant strong {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.message-assistant code {
|
||||
background: rgba(124, 108, 240, 0.15);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.message-assistant pre {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message-assistant pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.message-assistant ul,
|
||||
.message-assistant ol {
|
||||
margin: 6px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.message-assistant li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.message-assistant blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(124, 108, 240, 0.05);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.message-assistant h1,
|
||||
.message-assistant h2,
|
||||
.message-assistant h3 {
|
||||
margin: 10px 0 6px 0;
|
||||
font-size: 1em;
|
||||
color: #e0d4ff;
|
||||
}
|
||||
|
||||
.message-assistant hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message-assistant table {
|
||||
margin: 8px 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.message-assistant a {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.chat-input-bar input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.voice-btn.recording {
|
||||
background: linear-gradient(135deg, #fb7185, #e11d48);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
|
||||
animation: recordPulse 1.8s infinite;
|
||||
}
|
||||
|
||||
/* ── Tables ───────────────────────────────────────────────────────── */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
tr {
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(124, 108, 240, 0.04);
|
||||
}
|
||||
|
||||
/* ── Badge ────────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-global {
|
||||
background: rgba(124, 108, 240, 0.15);
|
||||
color: #a99bff;
|
||||
}
|
||||
|
||||
.badge-personal {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.badge-homework {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 200;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--shadow-lg), 0 0 60px rgba(124, 108, 240, 0.08);
|
||||
animation: modalIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* ── Toast ────────────────────────────────────────────────────────── */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, rgba(52, 211, 153, 0.9), rgba(16, 185, 129, 0.9));
|
||||
color: #0c2a1c;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, rgba(251, 113, 133, 0.9), rgba(225, 29, 72, 0.9));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Animations ───────────────────────────────────────────────────── */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.96);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 14px rgba(251, 113, 133, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-40px, 30px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(20px, -20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Utilities ────────────────────────────────────────────────────── */
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-24 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mb-16 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 108, 240, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 108, 240, 0.4);
|
||||
}
|
||||
|
||||
/* ── Voice status & indicators ────────────────────────────────────── */
|
||||
.voice-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.voice-status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
animation: recordPulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Loading spinner ──────────────────────────────────────────────── */
|
||||
.spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Selection highlight ──────────────────────────────────────────── */
|
||||
::selection {
|
||||
background: rgba(124, 108, 240, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
36
frontend/index.html
Normal file
36
frontend/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="FluentGerman.ai — Your personal AI-powered German language tutor">
|
||||
<title>FluentGerman.ai — Login</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-center">
|
||||
<div class="card card-sm">
|
||||
<div class="text-center mb-16">
|
||||
<div class="logo">FluentGerman.ai</div>
|
||||
<p class="subtitle">Your personal AI German tutor</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="Enter your username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="Enter your password" required>
|
||||
</div>
|
||||
<div id="login-error" class="text-sm hidden" style="color: var(--danger); margin-bottom: 12px;"></div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
341
frontend/js/admin.js
Normal file
341
frontend/js/admin.js
Normal file
@@ -0,0 +1,341 @@
|
||||
/* FluentGerman.ai — Admin panel logic */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!requireAuth() || !requireAdmin()) return;
|
||||
|
||||
const user = getUser();
|
||||
document.getElementById('admin-name').textContent = user?.username || 'Admin';
|
||||
|
||||
// Tab switching
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const panels = document.querySelectorAll('.tab-panel');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
panels.forEach(p => p.classList.add('hidden'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(tab.dataset.panel).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Users ──────────────────────────────────────────────────────
|
||||
const usersBody = document.getElementById('users-body');
|
||||
const userModal = document.getElementById('user-modal');
|
||||
const userForm = document.getElementById('user-form');
|
||||
let editingUserId = null;
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await apiJSON('/users/');
|
||||
usersBody.innerHTML = '';
|
||||
|
||||
if (users.length === 0) {
|
||||
usersBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No clients yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(u => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${u.username}</td>
|
||||
<td class="hide-mobile">${u.email}</td>
|
||||
<td><span class="badge ${u.is_active ? 'badge-personal' : 'badge-homework'}">${u.is_active ? 'Active' : 'Inactive'}</span></td>
|
||||
<td class="hide-mobile">${new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="editUser(${u.id})">Edit</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="manageInstructions(${u.id}, '${u.username}')">📝</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">✕</button>
|
||||
</td>
|
||||
`;
|
||||
usersBody.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.showUserModal = (editing = false) => {
|
||||
editingUserId = null;
|
||||
document.getElementById('user-modal-title').textContent = 'Add Client';
|
||||
userForm.reset();
|
||||
document.getElementById('user-password').required = true;
|
||||
userModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.closeUserModal = () => {
|
||||
userModal.classList.add('hidden');
|
||||
editingUserId = null;
|
||||
};
|
||||
|
||||
window.editUser = async (id) => {
|
||||
try {
|
||||
const users = await apiJSON('/users/');
|
||||
const u = users.find(x => x.id === id);
|
||||
if (!u) return;
|
||||
|
||||
editingUserId = id;
|
||||
document.getElementById('user-modal-title').textContent = 'Edit Client';
|
||||
document.getElementById('user-username').value = u.username;
|
||||
document.getElementById('user-email').value = u.email;
|
||||
document.getElementById('user-password').value = '';
|
||||
document.getElementById('user-password').required = false;
|
||||
userModal.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
userForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
username: document.getElementById('user-username').value.trim(),
|
||||
email: document.getElementById('user-email').value.trim(),
|
||||
};
|
||||
const password = document.getElementById('user-password').value;
|
||||
if (password) data.password = password;
|
||||
|
||||
try {
|
||||
if (editingUserId) {
|
||||
await apiJSON(`/users/${editingUserId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
showToast('Client updated');
|
||||
} else {
|
||||
data.password = password;
|
||||
await apiJSON('/users/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
showToast('Client created');
|
||||
}
|
||||
closeUserModal();
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
window.deleteUser = async (id) => {
|
||||
if (!confirm('Delete this client? This will also remove all their instructions.')) return;
|
||||
try {
|
||||
await api(`/users/${id}`, { method: 'DELETE' });
|
||||
showToast('Client deleted');
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Instructions ───────────────────────────────────────────────
|
||||
const instructionsBody = document.getElementById('instructions-body');
|
||||
const instrModal = document.getElementById('instruction-modal');
|
||||
const instrForm = document.getElementById('instruction-form');
|
||||
const instrUserSelect = document.getElementById('instr-user');
|
||||
let editingInstrId = null;
|
||||
let currentFilterUserId = null;
|
||||
|
||||
async function loadInstructions(userId = null) {
|
||||
try {
|
||||
const url = userId ? `/instructions/?user_id=${userId}` : '/instructions/';
|
||||
const instructions = await apiJSON(url);
|
||||
instructionsBody.innerHTML = '';
|
||||
|
||||
if (instructions.length === 0) {
|
||||
instructionsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No instructions yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
instructions.forEach(inst => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${inst.title}</td>
|
||||
<td><span class="badge badge-${inst.type}">${inst.type}</span></td>
|
||||
<td class="hide-mobile">${inst.user_id || 'Global'}</td>
|
||||
<td class="hide-mobile">${inst.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="editInstruction(${inst.id})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteInstruction(${inst.id})">✕</button>
|
||||
</td>
|
||||
`;
|
||||
instructionsBody.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
try {
|
||||
const users = await apiJSON('/users/');
|
||||
instrUserSelect.innerHTML = '<option value="">Global (all clients)</option>';
|
||||
users.forEach(u => {
|
||||
instrUserSelect.innerHTML += `<option value="${u.id}">${u.username}</option>`;
|
||||
});
|
||||
} catch (e) {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
window.manageInstructions = (userId, username) => {
|
||||
currentFilterUserId = userId;
|
||||
// Switch to instructions tab
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
panels.forEach(p => p.classList.add('hidden'));
|
||||
document.querySelector('[data-panel="instructions-panel"]').classList.add('active');
|
||||
document.getElementById('instructions-panel').classList.remove('hidden');
|
||||
loadInstructions(userId);
|
||||
};
|
||||
|
||||
window.showInstructionModal = () => {
|
||||
editingInstrId = null;
|
||||
document.getElementById('instr-modal-title').textContent = 'Add Instruction';
|
||||
instrForm.reset();
|
||||
loadUserOptions();
|
||||
instrModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.closeInstructionModal = () => {
|
||||
instrModal.classList.add('hidden');
|
||||
editingInstrId = null;
|
||||
};
|
||||
|
||||
window.editInstruction = async (id) => {
|
||||
try {
|
||||
const instructions = await apiJSON('/instructions/');
|
||||
const inst = instructions.find(x => x.id === id);
|
||||
if (!inst) return;
|
||||
|
||||
editingInstrId = id;
|
||||
await loadUserOptions();
|
||||
document.getElementById('instr-modal-title').textContent = 'Edit Instruction';
|
||||
document.getElementById('instr-title').value = inst.title;
|
||||
document.getElementById('instr-content').value = inst.content;
|
||||
document.getElementById('instr-type').value = inst.type;
|
||||
instrUserSelect.value = inst.user_id || '';
|
||||
instrModal.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
instrForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
title: document.getElementById('instr-title').value.trim(),
|
||||
content: document.getElementById('instr-content').value.trim(),
|
||||
type: document.getElementById('instr-type').value,
|
||||
user_id: instrUserSelect.value ? parseInt(instrUserSelect.value) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingInstrId) {
|
||||
await apiJSON(`/instructions/${editingInstrId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
showToast('Instruction updated');
|
||||
} else {
|
||||
await apiJSON('/instructions/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
showToast('Instruction created');
|
||||
}
|
||||
closeInstructionModal();
|
||||
loadInstructions(currentFilterUserId);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
window.deleteInstruction = async (id) => {
|
||||
if (!confirm('Delete this instruction?')) return;
|
||||
try {
|
||||
await api(`/instructions/${id}`, { method: 'DELETE' });
|
||||
showToast('Instruction deleted');
|
||||
loadInstructions(currentFilterUserId);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── File upload for instructions ────────────────────────────────
|
||||
window.uploadInstructionFile = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const text = await file.text();
|
||||
document.getElementById('instr-title').value = file.name.replace(/\.[^.]+$/, '');
|
||||
document.getElementById('instr-content').value = text;
|
||||
showInstructionModal();
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
// ── Voice instruction generation ───────────────────────────────
|
||||
const voiceGenBtn = document.getElementById('voice-gen-btn');
|
||||
const voiceGenOutput = document.getElementById('voice-gen-output');
|
||||
const voiceGenText = document.getElementById('voice-gen-text');
|
||||
const voice = new VoiceManager();
|
||||
|
||||
voice.init().then(() => {
|
||||
voice.onResult = (text) => {
|
||||
document.getElementById('voice-raw-text').value = text;
|
||||
};
|
||||
|
||||
voice.onStateChange = (recording) => {
|
||||
const btn = document.getElementById('voice-record-btn');
|
||||
btn.classList.toggle('recording', recording);
|
||||
btn.textContent = recording ? '⏹ Stop Recording' : '🎤 Start Recording';
|
||||
};
|
||||
});
|
||||
|
||||
window.toggleVoiceRecord = () => voice.toggleRecording();
|
||||
|
||||
window.generateInstruction = async () => {
|
||||
const rawText = document.getElementById('voice-raw-text').value.trim();
|
||||
if (!rawText) {
|
||||
showToast('Please record or type some text first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
voiceGenBtn.disabled = true;
|
||||
voiceGenBtn.textContent = 'Generating...';
|
||||
|
||||
try {
|
||||
const result = await apiJSON('/voice/generate-instruction', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ raw_text: rawText }),
|
||||
});
|
||||
|
||||
voiceGenText.value = result.instruction;
|
||||
voiceGenOutput.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
|
||||
voiceGenBtn.disabled = false;
|
||||
voiceGenBtn.textContent = '✨ Generate Instruction';
|
||||
};
|
||||
|
||||
window.saveGeneratedInstruction = () => {
|
||||
const content = voiceGenText.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
loadUserOptions();
|
||||
document.getElementById('instr-content').value = content;
|
||||
document.getElementById('instr-title').value = 'Voice Generated Instruction';
|
||||
showInstructionModal();
|
||||
};
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
loadUsers();
|
||||
loadInstructions();
|
||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||
});
|
||||
105
frontend/js/api.js
Normal file
105
frontend/js/api.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/* FluentGerman.ai — API client with auth */
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('fg_token');
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
localStorage.setItem('fg_token', token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
localStorage.removeItem('fg_token');
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
const data = localStorage.getItem('fg_user');
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
function setUser(user) {
|
||||
localStorage.setItem('fg_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
localStorage.removeItem('fg_user');
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { ...options.headers };
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Don't set Content-Type for FormData (browser sets it with boundary)
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
clearUser();
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function apiJSON(path, options = {}) {
|
||||
const response = await api(path, options);
|
||||
if (!response || !response.ok) {
|
||||
const error = await response?.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (!getToken()) {
|
||||
window.location.href = '/';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireAdmin() {
|
||||
const user = getUser();
|
||||
if (!user?.is_admin) {
|
||||
window.location.href = '/chat.html';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
clearUser();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
function showToast(message, type = 'success') {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
56
frontend/js/auth.js
Normal file
56
frontend/js/auth.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/* FluentGerman.ai — Auth page logic */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// If already logged in, redirect
|
||||
const token = getToken();
|
||||
const user = getUser();
|
||||
if (token && user) {
|
||||
window.location.href = user.is_admin ? '/admin.html' : '/chat.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const errorEl = document.getElementById('login-error');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
errorEl.textContent = 'Please fill in all fields.';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setToken(data.access_token);
|
||||
|
||||
// Fetch user info
|
||||
const meResp = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${data.access_token}` },
|
||||
});
|
||||
const userData = await meResp.json();
|
||||
setUser(userData);
|
||||
|
||||
window.location.href = userData.is_admin ? '/admin.html' : '/chat.html';
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
153
frontend/js/chat.js
Normal file
153
frontend/js/chat.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/* FluentGerman.ai — Chat interface logic */
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true, // GitHub-flavored markdown
|
||||
});
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML.
|
||||
*/
|
||||
function renderMarkdown(text) {
|
||||
const raw = marked.parse(text);
|
||||
return DOMPurify.sanitize(raw);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!requireAuth()) return;
|
||||
|
||||
const user = getUser();
|
||||
document.getElementById('user-name').textContent = user?.username || 'User';
|
||||
|
||||
const messagesEl = document.getElementById('chat-messages');
|
||||
const inputEl = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const voiceBtn = document.getElementById('voice-btn');
|
||||
|
||||
let history = [];
|
||||
|
||||
// Init voice
|
||||
const voice = new VoiceManager();
|
||||
await voice.init();
|
||||
|
||||
voice.onResult = (text) => {
|
||||
inputEl.value = text;
|
||||
voice.lastInputWasVoice = true;
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
voice.onStateChange = (recording) => {
|
||||
voiceBtn.classList.toggle('recording', recording);
|
||||
voiceBtn.textContent = recording ? '⏹' : '🎤';
|
||||
};
|
||||
|
||||
voiceBtn.addEventListener('click', () => voice.toggleRecording());
|
||||
|
||||
// Chat
|
||||
function appendMessage(role, content) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message message-${role}`;
|
||||
|
||||
if (role === 'assistant') {
|
||||
div.innerHTML = renderMarkdown(content);
|
||||
} else {
|
||||
div.textContent = content;
|
||||
}
|
||||
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Capture whether this was a voice input BEFORE clearing
|
||||
const wasVoice = voice.lastInputWasVoice;
|
||||
voice.lastInputWasVoice = false;
|
||||
|
||||
inputEl.value = '';
|
||||
sendBtn.disabled = true;
|
||||
|
||||
appendMessage('user', text);
|
||||
history.push({ role: 'user', content: text });
|
||||
|
||||
// Create assistant message placeholder
|
||||
const assistantEl = appendMessage('assistant', '');
|
||||
let fullResponse = '';
|
||||
|
||||
try {
|
||||
const response = await api('/chat/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message: text, history: history.slice(-20) }),
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
const errData = await response?.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || `Chat failed (${response?.status})`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.token) {
|
||||
fullResponse += parsed.token;
|
||||
// Live-render markdown as tokens stream in
|
||||
assistantEl.innerHTML = renderMarkdown(fullResponse);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
if (parsed.error) {
|
||||
showToast(parsed.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
// skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullResponse) {
|
||||
history.push({ role: 'assistant', content: fullResponse });
|
||||
|
||||
// Auto-speak response if the user used voice input
|
||||
if (wasVoice) {
|
||||
await voice.speak(fullResponse);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
assistantEl.textContent = 'Sorry, something went wrong. Please try again.';
|
||||
showToast(e.message, 'error');
|
||||
console.error('[Chat] Error:', e);
|
||||
}
|
||||
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||
});
|
||||
230
frontend/js/voice.js
Normal file
230
frontend/js/voice.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/* FluentGerman.ai — Voice module (Web Speech API + API mode) */
|
||||
|
||||
class VoiceManager {
|
||||
constructor() {
|
||||
this.mode = 'browser'; // will be set from server config
|
||||
this.recognition = null;
|
||||
this.synthesis = window.speechSynthesis;
|
||||
this.isRecording = false;
|
||||
this.lastInputWasVoice = false; // tracks if last message was spoken
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.onResult = null;
|
||||
this.onStateChange = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Always init browser STT as fallback
|
||||
this._initBrowserSTT();
|
||||
|
||||
// Fetch voice mode from server
|
||||
try {
|
||||
const response = await api('/voice/config');
|
||||
if (response?.ok) {
|
||||
const config = await response.json();
|
||||
this.mode = config.voice_mode;
|
||||
console.log('[Voice] Mode:', this.mode);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Voice] Could not fetch config, using browser mode');
|
||||
this.mode = 'browser';
|
||||
}
|
||||
}
|
||||
|
||||
_initBrowserSTT() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
console.warn('[Voice] Speech recognition not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.continuous = false;
|
||||
this.recognition.interimResults = false;
|
||||
this.recognition.lang = 'de-DE';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
const text = event.results[0][0].transcript;
|
||||
console.log('[Voice] Browser STT result:', text);
|
||||
this.lastInputWasVoice = true;
|
||||
if (this.onResult) this.onResult(text);
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
console.log('[Voice] Browser STT ended');
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.error('[Voice] Browser STT error:', event.error);
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
|
||||
if (event.error === 'not-allowed') {
|
||||
showToast('Microphone access denied. Please allow microphone in browser settings.', 'error');
|
||||
} else if (event.error === 'no-speech') {
|
||||
showToast('No speech detected. Try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Voice] Browser STT initialized');
|
||||
}
|
||||
|
||||
async startRecording() {
|
||||
this.isRecording = true;
|
||||
this.lastInputWasVoice = true;
|
||||
if (this.onStateChange) this.onStateChange(true);
|
||||
|
||||
if (this.mode === 'api') {
|
||||
// API mode — record audio via MediaRecorder, send to Whisper
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.audioChunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
this.audioChunks.push(event.data);
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
await this._transcribeAPI(blob);
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
console.log('[Voice] API recording started');
|
||||
} catch (e) {
|
||||
console.error('[Voice] Microphone access error:', e);
|
||||
showToast('Microphone access denied', 'error');
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
}
|
||||
} else {
|
||||
// Browser mode — use Web Speech API
|
||||
if (this.recognition) {
|
||||
try {
|
||||
this.recognition.start();
|
||||
console.log('[Voice] Browser STT started');
|
||||
} catch (e) {
|
||||
console.error('[Voice] Failed to start recognition:', e);
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
showToast('Voice recognition failed to start. Try again.', 'error');
|
||||
}
|
||||
} else {
|
||||
console.warn('[Voice] No speech recognition available');
|
||||
showToast('Speech recognition not supported in this browser', 'error');
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
console.log('[Voice] Stopping recording...');
|
||||
if (this.mode === 'api') {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
|
||||
this.mediaRecorder.stop();
|
||||
} else {
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
}
|
||||
} else {
|
||||
if (this.recognition) {
|
||||
try {
|
||||
this.recognition.stop();
|
||||
} catch (e) {
|
||||
// Already stopped
|
||||
}
|
||||
}
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
async _transcribeAPI(blob) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'recording.webm');
|
||||
|
||||
console.log('[Voice] Sending audio to API for transcription...');
|
||||
const response = await api('/voice/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response?.ok) {
|
||||
const data = await response.json();
|
||||
console.log('[Voice] API transcription result:', data.text);
|
||||
this.lastInputWasVoice = true;
|
||||
if (this.onResult) this.onResult(data.text);
|
||||
} else {
|
||||
showToast('Transcription failed. Falling back to browser voice.', 'error');
|
||||
// Fallback: switch to browser mode for this session
|
||||
this.mode = 'browser';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Voice] API transcription error:', e);
|
||||
showToast('Transcription error', 'error');
|
||||
} finally {
|
||||
this.isRecording = false;
|
||||
if (this.onStateChange) this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
async speak(text) {
|
||||
if (this.mode === 'api') {
|
||||
return this._speakAPI(text);
|
||||
} else {
|
||||
return this._speakBrowser(text);
|
||||
}
|
||||
}
|
||||
|
||||
_speakBrowser(text) {
|
||||
return new Promise((resolve) => {
|
||||
// Cancel any ongoing speech
|
||||
this.synthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'de-DE';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = resolve;
|
||||
utterance.onerror = () => {
|
||||
console.warn('[Voice] Browser TTS error');
|
||||
resolve();
|
||||
};
|
||||
this.synthesis.speak(utterance);
|
||||
});
|
||||
}
|
||||
|
||||
async _speakAPI(text) {
|
||||
try {
|
||||
const response = await api(`/voice/synthesize?text=${encodeURIComponent(text)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response?.ok) {
|
||||
const audioBlob = await response.blob();
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
await audio.play();
|
||||
return new Promise(resolve => {
|
||||
audio.onended = resolve;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Voice] API TTS failed, falling back to browser');
|
||||
}
|
||||
// Fallback to browser TTS
|
||||
return this._speakBrowser(text);
|
||||
}
|
||||
|
||||
toggleRecording() {
|
||||
if (this.isRecording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
this.startRecording();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user