bugfix speech-to-text and implement avatar and personal greeting
All checks were successful
Deploy FluentGerman.ai / deploy (push) Successful in 53s

This commit is contained in:
2026-02-16 20:11:09 +01:00
parent 8f5bfa3cbc
commit 84cd052ded
8 changed files with 284 additions and 19 deletions

View File

@@ -5,12 +5,13 @@ import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user from app.auth import get_current_user
from app.database import get_db from app.database import get_db
from app.models import User from app.models import Instruction, User
from app.schemas import ChatRequest from app.schemas import ChatRequest, DashboardOut
from app.services.instruction_service import get_system_prompt from app.services.instruction_service import get_system_prompt
from app.services.llm_service import chat_stream 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 = 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("/") @router.post("/")
async def chat( async def chat(
body: ChatRequest, body: ChatRequest,

View File

@@ -66,6 +66,12 @@ class InstructionOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Dashboard ────────────────────────────────────────────────────────
class DashboardOut(BaseModel):
username: str
latest_instruction_at: datetime | None = None
# ── Chat ────────────────────────────────────────────────────────────── # ── Chat ──────────────────────────────────────────────────────────────
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
role: str # "user" | "assistant" role: str # "user" | "assistant"

View File

@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name _; # Replace with your domain server_name fluentgerman.thiessen.io; # Replace with your domain
# Frontend static files # Frontend static files
location / { location / {
@@ -10,7 +10,7 @@ server {
# API proxy # API proxy
location /api/ { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -10,7 +10,7 @@
server { server {
listen 80; 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) # Redirect HTTP → HTTPS (uncomment after certbot setup)
# return 301 https://$host$request_uri; # return 301 https://$host$request_uri;

View File

@@ -24,9 +24,20 @@
<!-- Chat --> <!-- Chat -->
<div class="chat-container"> <div class="chat-container">
<div class="chat-messages" id="chat-messages"> <div class="chat-messages" id="chat-messages">
<div class="message message-assistant"> <!-- Welcome section injected by JS -->
Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone <div class="welcome-section" id="welcome-section">
to speak. <div class="welcome-row">
<div class="avatar-container" id="avatar-container">
<div class="avatar-ring"></div>
<div class="avatar-placeholder">🎓</div>
<div class="avatar-pulse"></div>
</div>
<div class="welcome-text">
<h2 class="welcome-greeting" id="welcome-greeting">Hallo! 👋</h2>
<p class="welcome-subtitle" id="welcome-subtitle">Your personal AI German tutor</p>
<p class="welcome-meta" id="welcome-meta"></p>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -687,6 +687,155 @@ tr:hover td {
color: #fff; 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 ───────────────────────────────────────────────────── */ /* ── Animations ───────────────────────────────────────────────────── */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
@@ -825,6 +974,27 @@ tr:hover td {
.hide-mobile { .hide-mobile {
display: none !important; 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 ────────────────────────────────────────────────────── */ /* ── Utilities ────────────────────────────────────────────────────── */

View File

@@ -14,11 +14,32 @@ function renderMarkdown(text) {
return DOMPurify.sanitize(raw); 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 () => { document.addEventListener('DOMContentLoaded', async () => {
if (!requireAuth()) return; if (!requireAuth()) return;
const user = getUser(); 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 messagesEl = document.getElementById('chat-messages');
const inputEl = document.getElementById('chat-input'); const inputEl = document.getElementById('chat-input');
@@ -27,7 +48,36 @@ document.addEventListener('DOMContentLoaded', async () => {
let history = []; 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 = `<span class="meta-dot"></span> Lessons last updated <strong>${ago}</strong>`;
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(); const voice = new VoiceManager();
await voice.init(); await voice.init();
@@ -44,7 +94,7 @@ document.addEventListener('DOMContentLoaded', async () => {
voiceBtn.addEventListener('click', () => voice.toggleRecording()); voiceBtn.addEventListener('click', () => voice.toggleRecording());
// Chat // ── Chat ──────────────────────────────────────────────────────────
function appendMessage(role, content) { function appendMessage(role, content) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `message message-${role}`; div.className = `message message-${role}`;

View File

@@ -11,10 +11,11 @@ class VoiceManager {
this.audioChunks = []; this.audioChunks = [];
this.onResult = null; this.onResult = null;
this.onStateChange = null; this.onStateChange = null;
this.browserSTTSupported = false;
} }
async init() { async init() {
// Always init browser STT as fallback // Check browser STT support
this._initBrowserSTT(); this._initBrowserSTT();
// Fetch voice mode from server // Fetch voice mode from server
@@ -23,21 +24,32 @@ class VoiceManager {
if (response?.ok) { if (response?.ok) {
const config = await response.json(); const config = await response.json();
this.mode = config.voice_mode; this.mode = config.voice_mode;
console.log('[Voice] Mode:', this.mode); console.log('[Voice] Server mode:', this.mode);
} }
} catch (e) { } catch (e) {
console.warn('[Voice] Could not fetch config, using browser mode'); console.warn('[Voice] Could not fetch config, using browser mode');
this.mode = 'browser'; 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() { _initBrowserSTT() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) { 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; return;
} }
this.browserSTTSupported = true;
this.recognition = new SpeechRecognition(); this.recognition = new SpeechRecognition();
this.recognition.continuous = false; this.recognition.continuous = false;
this.recognition.interimResults = false; this.recognition.interimResults = false;
@@ -114,10 +126,12 @@ class VoiceManager {
showToast('Voice recognition failed to start. Try again.', 'error'); showToast('Voice recognition failed to start. Try again.', 'error');
} }
} else { } else {
console.warn('[Voice] No speech recognition available'); // Shouldn't happen after init() fallback, but safety net
showToast('Speech recognition not supported in this browser', 'error'); console.warn('[Voice] No speech recognition available, switching to API');
this.mode = 'api';
this.isRecording = false; this.isRecording = false;
if (this.onStateChange) this.onStateChange(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; this.lastInputWasVoice = true;
if (this.onResult) this.onResult(data.text); if (this.onResult) this.onResult(data.text);
} else { } else {
showToast('Transcription failed. Falling back to browser voice.', 'error'); showToast('Transcription failed. Please try again.', 'error');
// Fallback: switch to browser mode for this session
this.mode = 'browser';
} }
} catch (e) { } catch (e) {
console.error('[Voice] API transcription error:', e); console.error('[Voice] API transcription error:', e);