From 84cd052ded2e6cdec8f457259c6ba2a00910eda3 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Mon, 16 Feb 2026 20:11:09 +0100 Subject: [PATCH] bugfix speech-to-text and implement avatar and personal greeting --- backend/app/routers/chat.py | 20 ++++- backend/app/schemas.py | 6 ++ deploy/nginx.conf | 4 +- deploy/nginx.conf.example | 2 +- frontend/chat.html | 17 +++- frontend/css/style.css | 170 ++++++++++++++++++++++++++++++++++++ frontend/js/chat.js | 56 +++++++++++- frontend/js/voice.js | 28 ++++-- 8 files changed, 284 insertions(+), 19 deletions(-) diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index 4883425..7c4cebc 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -5,12 +5,13 @@ import logging from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse +from sqlalchemy import select, func 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.models import Instruction, User +from app.schemas import ChatRequest, DashboardOut from app.services.instruction_service import get_system_prompt from app.services.llm_service import chat_stream @@ -19,6 +20,21 @@ logger = logging.getLogger("fluentgerman.chat") router = APIRouter(prefix="/api/chat", tags=["chat"]) +@router.get("/dashboard", response_model=DashboardOut) +async def dashboard( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return personalised dashboard data: username + latest instruction date.""" + result = await db.execute( + select(func.max(Instruction.created_at)).where( + (Instruction.user_id == user.id) | Instruction.user_id.is_(None) + ) + ) + latest = result.scalar_one_or_none() + return DashboardOut(username=user.username, latest_instruction_at=latest) + + @router.post("/") async def chat( body: ChatRequest, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4502e06..7288501 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -66,6 +66,12 @@ class InstructionOut(BaseModel): model_config = {"from_attributes": True} +# ── Dashboard ──────────────────────────────────────────────────────── +class DashboardOut(BaseModel): + username: str + latest_instruction_at: datetime | None = None + + # ── Chat ────────────────────────────────────────────────────────────── class ChatMessage(BaseModel): role: str # "user" | "assistant" diff --git a/deploy/nginx.conf b/deploy/nginx.conf index fd1ecae..5696eb0 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name _; # Replace with your domain + server_name fluentgerman.thiessen.io; # Replace with your domain # Frontend static files location / { @@ -10,7 +10,7 @@ server { # API proxy location /api/ { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://127.0.0.1:8999; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index 7cff458..5a55083 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -10,7 +10,7 @@ server { listen 80; - server_name fluentgerman.mydomain.io; # ← Replace with your subdomain + server_name fluentgerman.thiessen.io; # ← Replace with your subdomain # Redirect HTTP → HTTPS (uncomment after certbot setup) # return 301 https://$host$request_uri; diff --git a/frontend/chat.html b/frontend/chat.html index 5b4feb5..bfee02b 100644 --- a/frontend/chat.html +++ b/frontend/chat.html @@ -24,9 +24,20 @@
-
- Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone - to speak. + +
+
+
+
+
🎓
+
+
+
+

Hallo! 👋

+

Your personal AI German tutor

+

+
+
diff --git a/frontend/css/style.css b/frontend/css/style.css index 31dc0d0..eacda03 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -687,6 +687,155 @@ tr:hover td { color: #fff; } +.toast-info { + background: linear-gradient(135deg, rgba(96, 165, 250, 0.9), rgba(59, 130, 246, 0.9)); + color: #fff; +} + +/* ── Welcome Section ─────────────────────────────────────────────── */ +.welcome-section { + background: var(--gradient-card); + 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: 28px 32px; + margin-bottom: 8px; + box-shadow: var(--shadow-md), var(--shadow-glow); + animation: messageIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; +} + +.welcome-row { + display: flex; + align-items: center; + gap: 24px; +} + +.welcome-text { + flex: 1; + min-width: 0; +} + +.welcome-greeting { + font-size: 1.4rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; + margin-bottom: 4px; +} + +.welcome-subtitle { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 8px; +} + +.welcome-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--text-muted); + opacity: 0; + transform: translateY(6px); + transition: opacity 0.4s ease, transform 0.4s ease; +} + +.welcome-meta.visible { + opacity: 1; + transform: translateY(0); +} + +.welcome-meta strong { + color: var(--text-secondary); +} + +.meta-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + flex-shrink: 0; + box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); +} + +/* ── Avatar ───────────────────────────────────────────────────────── */ +.avatar-container { + position: relative; + width: 72px; + height: 72px; + flex-shrink: 0; +} + +.avatar-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid transparent; + background: var(--gradient-primary) border-box; + -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + animation: avatarSpin 6s linear infinite; +} + +.avatar-placeholder { + position: absolute; + inset: 4px; + border-radius: 50%; + background: var(--glass-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + animation: avatarBreathe 3s ease-in-out infinite; + box-shadow: inset 0 0 20px rgba(124, 108, 240, 0.08); +} + +.avatar-pulse { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1px solid rgba(124, 108, 240, 0.15); + animation: avatarPulseAnim 3s ease-in-out infinite; +} + +@keyframes avatarSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes avatarBreathe { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.03); + } +} + +@keyframes avatarPulseAnim { + + 0%, + 100% { + transform: scale(1); + opacity: 0.5; + } + + 50% { + transform: scale(1.12); + opacity: 0; + } +} + /* ── Animations ───────────────────────────────────────────────────── */ @keyframes fadeIn { from { @@ -825,6 +974,27 @@ tr:hover td { .hide-mobile { display: none !important; } + + .welcome-section { + padding: 20px; + } + + .welcome-row { + gap: 16px; + } + + .avatar-container { + width: 56px; + height: 56px; + } + + .avatar-placeholder { + font-size: 1.4rem; + } + + .welcome-greeting { + font-size: 1.15rem; + } } /* ── Utilities ────────────────────────────────────────────────────── */ diff --git a/frontend/js/chat.js b/frontend/js/chat.js index 75699b9..2e5989c 100644 --- a/frontend/js/chat.js +++ b/frontend/js/chat.js @@ -14,11 +14,32 @@ function renderMarkdown(text) { return DOMPurify.sanitize(raw); } +/** + * Format a date as a friendly relative time string. + */ +function relativeTime(dateStr) { + if (!dateStr) return null; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 30) return `${diffDays} days ago`; + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + document.addEventListener('DOMContentLoaded', async () => { if (!requireAuth()) return; const user = getUser(); - document.getElementById('user-name').textContent = user?.username || 'User'; + const displayName = user?.username || 'User'; + document.getElementById('user-name').textContent = displayName; const messagesEl = document.getElementById('chat-messages'); const inputEl = document.getElementById('chat-input'); @@ -27,7 +48,36 @@ document.addEventListener('DOMContentLoaded', async () => { let history = []; - // Init voice + // ── Personalised welcome ────────────────────────────────────────── + const greetingEl = document.getElementById('welcome-greeting'); + const subtitleEl = document.getElementById('welcome-subtitle'); + const metaEl = document.getElementById('welcome-meta'); + + // Immediately show the username we already have from localStorage + greetingEl.textContent = `Hallo, ${displayName}! 👋`; + + // Fetch dashboard info for latest instruction data + try { + const resp = await api('/chat/dashboard'); + if (resp?.ok) { + const data = await resp.json(); + // Use server username (authoritative) + greetingEl.textContent = `Hallo, ${data.username}! 👋`; + + if (data.latest_instruction_at) { + const ago = relativeTime(data.latest_instruction_at); + metaEl.innerHTML = ` Lessons last updated ${ago}`; + metaEl.classList.add('visible'); + } else { + metaEl.textContent = 'No custom lessons configured yet'; + metaEl.classList.add('visible'); + } + } + } catch (e) { + console.warn('[Chat] Could not fetch dashboard info:', e); + } + + // ── Voice ───────────────────────────────────────────────────────── const voice = new VoiceManager(); await voice.init(); @@ -44,7 +94,7 @@ document.addEventListener('DOMContentLoaded', async () => { voiceBtn.addEventListener('click', () => voice.toggleRecording()); - // Chat + // ── Chat ────────────────────────────────────────────────────────── function appendMessage(role, content) { const div = document.createElement('div'); div.className = `message message-${role}`; diff --git a/frontend/js/voice.js b/frontend/js/voice.js index 33c8ef1..4c2526d 100644 --- a/frontend/js/voice.js +++ b/frontend/js/voice.js @@ -11,10 +11,11 @@ class VoiceManager { this.audioChunks = []; this.onResult = null; this.onStateChange = null; + this.browserSTTSupported = false; } async init() { - // Always init browser STT as fallback + // Check browser STT support this._initBrowserSTT(); // Fetch voice mode from server @@ -23,21 +24,32 @@ class VoiceManager { if (response?.ok) { const config = await response.json(); this.mode = config.voice_mode; - console.log('[Voice] Mode:', this.mode); + console.log('[Voice] Server mode:', this.mode); } } catch (e) { console.warn('[Voice] Could not fetch config, using browser mode'); this.mode = 'browser'; } + + // Auto-fallback: if server says "browser" but browser doesn't support STT, use API + if (this.mode === 'browser' && !this.browserSTTSupported) { + console.warn('[Voice] Browser STT not supported, falling back to API mode'); + this.mode = 'api'; + showToast('Using cloud voice recognition — your browser doesn\'t support built-in speech recognition.', 'info'); + } + + console.log('[Voice] Active mode:', this.mode); } _initBrowserSTT() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { - console.warn('[Voice] Speech recognition not supported in this browser'); + console.warn('[Voice] SpeechRecognition API not available in this browser'); + this.browserSTTSupported = false; return; } + this.browserSTTSupported = true; this.recognition = new SpeechRecognition(); this.recognition.continuous = false; this.recognition.interimResults = false; @@ -114,10 +126,12 @@ class VoiceManager { 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'); + // Shouldn't happen after init() fallback, but safety net + console.warn('[Voice] No speech recognition available, switching to API'); + this.mode = 'api'; this.isRecording = false; if (this.onStateChange) this.onStateChange(false); + showToast('Switched to cloud voice recognition. Please try again.', 'info'); } } } @@ -161,9 +175,7 @@ class VoiceManager { 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'; + showToast('Transcription failed. Please try again.', 'error'); } } catch (e) { console.error('[Voice] API transcription error:', e);