bugfix speech-to-text and implement avatar and personal greeting
All checks were successful
Deploy FluentGerman.ai / deploy (push) Successful in 53s
All checks were successful
Deploy FluentGerman.ai / deploy (push) Successful in 53s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,9 +24,20 @@
|
||||
<!-- 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.
|
||||
<!-- Welcome section injected by JS -->
|
||||
<div class="welcome-section" id="welcome-section">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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 = `<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();
|
||||
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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user