initial commit
This commit is contained in:
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