From 67c0e9e8af10d355a74703771955ed26f6225e8c Mon Sep 17 00:00:00 2001 From: Christian Rute Date: Mon, 7 Apr 2025 22:22:12 +0200 Subject: [PATCH] refactor + new look --- client/admin.html | 66 +- client/css/styles.css | 1158 +++++++++++++++++++-------------- client/index.html | 53 +- client/js/api_service.js | 168 +++++ client/js/chat_manager.js | 256 ++++++++ client/js/chat_ui.js | 193 ++++++ client/js/config.js | 3 + client/js/main.js | 484 ++++++++++---- client/js/shared_functions.js | 76 ++- client/welcome.html | 111 ++-- 10 files changed, 1844 insertions(+), 724 deletions(-) create mode 100644 client/js/api_service.js create mode 100644 client/js/chat_manager.js create mode 100644 client/js/chat_ui.js create mode 100644 client/js/config.js diff --git a/client/admin.html b/client/admin.html index 085dbf9..ccbb1c0 100644 --- a/client/admin.html +++ b/client/admin.html @@ -4,32 +4,64 @@ Admin-Bereich + + + + + - - - + +
-

Admin-Bereich

- +

Admin Bereich

- - + + +
- -
-
+ + + - + \ No newline at end of file diff --git a/client/js/api_service.js b/client/js/api_service.js new file mode 100644 index 0000000..4acda5b --- /dev/null +++ b/client/js/api_service.js @@ -0,0 +1,168 @@ +import { BASE_API_URL, API_KEY } from './config.js'; +import { showAlert } from './shared_functions.js'; + +/** + * Generic fetch wrapper for API calls + * @param {string} endpoint API endpoint (e.g., '/api/auth/me') + * @param {object} options Fetch options (method, headers, body) + * @param {boolean} includeAuthHeader Add Authorization header? + * @returns {Promise} Parsed JSON response or throws error + */ +async function fetchApi(endpoint, options = {}, includeAuthHeader = true) { + const url = `${BASE_API_URL}${endpoint}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (includeAuthHeader) { + const token = localStorage.getItem('token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } else { + // Handle cases where token is expected but missing (e.g., redirect to login) + console.warn('Auth token missing for API call:', endpoint); + // Optionally throw an error or redirect here + // window.location.href = 'index.html'; + // throw new Error('Authentication token not found.'); + } + } + + try { + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (e) { + // If response is not JSON + errorData = { message: response.statusText }; + } + console.error(`API Error ${response.status}:`, errorData); + const error = new Error(errorData.message || `HTTP error! status: ${response.status}`); + error.status = response.status; + error.data = errorData; + throw error; + } + + // Handle responses with no content (e.g., DELETE) + if (response.status === 204) { + return null; + } + + return await response.json(); + + } catch (error) { + console.error(`Fetch error for ${url}:`, error); + // Re-throw generic network errors or specific API errors + if (error instanceof TypeError && error.message.includes('Failed to fetch')) { + throw new Error(`Network Error: Could not connect to API (${url}).`); + } + throw error; // Re-throw API errors or other exceptions + } +} + + +// --- Specific API Functions --- + +export async function getUserData() { + return await fetchApi('/api/auth/me', { method: 'GET' }); +} + +export async function getAdminData() { + return await fetchApi('/api/auth/admin', { method: 'GET' }); +} + +export async function getAllUsers() { + return await fetchApi('/api/auth/users', { method: 'GET' }); +} + +export async function deleteUserById(userId) { + return await fetchApi(`/api/auth/user/${userId}`, { method: 'DELETE' }); +} + +export async function getAvailableModels() { + // Note: OpenAI endpoints might not require the Bearer token if using a proxy/backend key + const requiresAuth = !API_KEY.startsWith('YOUR_'); // Simple check if a real key seems present + const headers = requiresAuth ? { 'Authorization': `Bearer ${API_KEY}` } : {}; + try { + // Using BASE_API_URL assumes your proxy handles the /v1 path + const data = await fetchApi('/v1/models', { method: 'GET', headers }, false); // Auth header likely not needed here if backend handles it + // Return the first model ID, or handle multiple models if needed + return data?.data?.[0]?.id || 'default-model-id'; // Provide a fallback + } catch (error) { + console.error("Error fetching models:", error); + showAlert("Fehler beim Abrufen der Modelle vom Server.", "error"); + throw error; // Allow caller to handle + } + +} + +export async function streamChatCompletion(messages, systemPrompt, model) { + const body = { + model: model, + messages: [{ role: "system", content: systemPrompt }, ...messages], + stream: true, + temperature: 0.5, // Adjust temperature as needed + }; + const requiresAuth = !API_KEY.startsWith('YOUR_'); + const headers = requiresAuth ? { 'Authorization': `Bearer ${API_KEY}` } : {}; + + // This needs to return the raw Response object to handle the stream + try { + const response = await fetch(`${BASE_API_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body) + }); + + if (!response.ok || !response.body) { + let errorData; + try { errorData = await response.json(); } catch (e) { errorData = { message: response.statusText }; } + console.error(`API Error ${response.status}:`, errorData); + const error = new Error(errorData.message || `HTTP error! status: ${response.status}`); + error.status = response.status; + throw error; + } + return response; // Return the raw response for stream handling + + } catch (error) { + console.error(`Fetch stream error:`, error); + if (error instanceof TypeError && error.message.includes('Failed to fetch')) { + throw new Error(`Network Error: Could not connect to API (${BASE_API_URL}).`); + } + throw error; + } +} + +export async function tokenizePrompt(promptMessages, systemPrompt, model) { + // Format messages array into the specific string format expected by your /tokenize endpoint + const formatArrayToCustomString = (array) => { + return '[' + + array.map(dict => '{' + Object.entries(dict).map(([key, value]) => `'${key}':'${value.replace(/'/g, "\\'")}'`).join(', ') + '}') // Escape single quotes in values + .join(', ') + + ']'; + }; + + const messages = [{ role: "system", content: systemPrompt }, ...promptMessages]; + const formattedString = `"${formatArrayToCustomString(messages)}"`; // Wrap in double quotes as per your example + + const body = { + model: model, + prompt: formattedString, + add_special_tokens: true // Assuming this is correct for your backend + }; + const requiresAuth = !API_KEY.startsWith('YOUR_'); + const headers = requiresAuth ? { 'Authorization': `Bearer ${API_KEY}` } : {}; + + try { + // Assuming /tokenize is relative to BASE_API_URL + return await fetchApi('/tokenize', { method: 'POST', body: JSON.stringify(body), headers }, false); + } catch (error) { + console.error("Error tokenizing prompt:", error); + showAlert("Fehler bei der Token-Berechnung.", "error"); + // Return a default structure or re-throw + return { count: 0, max_model_len: 4096 }; // Example fallback + } +} \ No newline at end of file diff --git a/client/js/chat_manager.js b/client/js/chat_manager.js new file mode 100644 index 0000000..dbc4f1e --- /dev/null +++ b/client/js/chat_manager.js @@ -0,0 +1,256 @@ +import * as api from './api_service.js'; +import * as ui from './chat_ui.js'; +import { showAlert, showConfirm } from './shared_functions.js'; + +let currentChatId = null; +let chatHistories = {}; // In-memory cache of chat histories { chatId: [messages] } +let availableModel = 'default-model-id'; // Fetched model ID +let systemPrompt = "You are a helpful and concise assistant."; // Default system prompt +let maxTokens = 4096; // Default max tokens, will be updated by tokenize + +// --- Initialization --- +export async function initializeChat() { + await loadAvailableModel(); + loadChatSessionsFromStorage(); + if (Object.keys(chatHistories).length > 0) { + // Load the most recent chat (assuming keys are somewhat ordered or track last used) + const lastChatId = Object.keys(chatHistories).sort().pop(); // Simple sort, might need better tracking + await loadChat(lastChatId); + } else { + // Create a new chat if none exist + await createNewChat(); + } + // Initial token calculation for the loaded chat + await updateTokenUsage(); +} + +async function loadAvailableModel() { + try { + availableModel = await api.getAvailableModels(); + console.log("Using model:", availableModel); + } catch (error) { + console.error("Failed to load model, using default:", error); + // Keep the default model ID + } +} + +// --- Chat Session Management --- +function loadChatSessionsFromStorage() { + chatHistories = {}; // Clear cache first + let hasChats = false; + Object.keys(localStorage).forEach((key) => { + if (key.startsWith('chatHistory-')) { + const chatId = key.substring('chatHistory-'.length); + try { + const history = JSON.parse(localStorage.getItem(key)); + if (Array.isArray(history)) { + chatHistories[chatId] = history; + const chatName = generateChatName(history, chatId); + ui.addChatToList({ id: chatId, name: chatName }, false, loadChat, deleteChat); // Add to UI + hasChats = true; + } else { + localStorage.removeItem(key); // Clean up invalid data + } + } catch (e) { + console.error(`Error parsing chat history for ${key}:`, e); + localStorage.removeItem(key); // Clean up corrupted data + } + } + }); + console.log("Loaded sessions:", Object.keys(chatHistories).length); +} + +export async function loadChat(chatId) { + if (!chatHistories[chatId]) { + console.error(`Chat history for ${chatId} not found.`); + showAlert(`Chat ${chatId} konnte nicht geladen werden.`, 'error'); + // Optionally load the first available chat or create new + const firstChatId = Object.keys(chatHistories)[0]; + if(firstChatId) await loadChat(firstChatId); + else await createNewChat(); + return; + } + if (currentChatId === chatId) return; // Already loaded + + currentChatId = chatId; + console.log(`Loading chat: ${chatId}`); + + ui.clearChatBox(); + chatHistories[currentChatId].forEach(msg => { + ui.displayMessage(msg.content, msg.role, { isMarkdown: msg.role === 'assistant' }); + }); + ui.setActiveChatInList(chatId); + ui.enableInput(); + await updateTokenUsage(); // Update token count for the newly loaded chat +} + +export async function createNewChat() { + currentChatId = `chat_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + chatHistories[currentChatId] = []; // Initialize empty history + saveChatHistoryToStorage(currentChatId); // Save empty chat immediately + + const chatName = generateChatName([], currentChatId); + ui.addChatToList({ id: currentChatId, name: chatName }, true, loadChat, deleteChat); // Add to UI and mark active + ui.clearChatBox(); + ui.displayMessage("Neuer Chat gestartet. Frag mich etwas!", "assistant"); + ui.setActiveChatInList(currentChatId); + ui.enableInput(); + await updateTokenUsage(); // Reset token count + console.log("Created new chat:", currentChatId); +} + +export async function deleteChat(chatId) { + const confirmed = await showConfirm(`Möchten Sie den Chat "${generateChatName(chatHistories[chatId], chatId)}" wirklich löschen?`, 'error'); + if (confirmed) { + console.log(`Deleting chat: ${chatId}`); + delete chatHistories[chatId]; // Remove from memory + localStorage.removeItem(`chatHistory-${chatId}`); // Remove from storage + ui.removeChatFromList(chatId); // Remove from UI + + // If the deleted chat was the current one, load another or create new + if (currentChatId === chatId) { + const remainingChatIds = Object.keys(chatHistories); + if (remainingChatIds.length > 0) { + await loadChat(remainingChatIds[0]); // Load the first remaining + } else { + await createNewChat(); // Create a new one if none left + } + } + showAlert('Chat gelöscht.', 'success'); + } +} + +// --- Message Handling --- +export async function handleSendMessage(messageText) { + if (!currentChatId || !messageText) return; + + ui.disableInput(true); // Disable input and show loading + + // 1. Add user message to history and UI + const userMessage = { role: 'user', content: messageText }; + chatHistories[currentChatId].push(userMessage); + ui.displayMessage(messageText, 'user'); + saveChatHistoryToStorage(currentChatId); // Save after adding user message + updateChatNameIfNeeded(currentChatId); // Update sidebar name if it's the first message + + // 2. Update token count *before* sending to AI (includes new user message) + await updateTokenUsage(); + + // 3. Get AI Response (Streaming) + let fullResponse = ''; + try { + const responseStream = await api.streamChatCompletion( + chatHistories[currentChatId], // Send current history + systemPrompt, + availableModel + ); + + // Process the stream + const reader = responseStream.body.getReader(); + const decoder = new TextDecoder('utf-8'); + ui.updateStreamingMessage('', true); // Initialize the streaming div + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + + const chunk = decoder.decode(value, { stream: true }); + // Handle potential multiple data chunks in one value + const lines = chunk.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6).trim(); + if (jsonStr === '[DONE]') { + break; // Stream finished signal + } + try { + const json = JSON.parse(jsonStr); + const deltaContent = json.choices?.[0]?.delta?.content; + if (deltaContent) { + fullResponse += deltaContent; + ui.updateStreamingMessage(deltaContent, true); // Update UI incrementally + } + } catch (error) { + if (jsonStr) { // Avoid logging empty lines as errors + console.warn('Error parsing stream JSON:', error, 'Received:', jsonStr); + } + } + } + } + } + + ui.finalizeStreamingMessage(); // Finalize the UI element + + // 4. Add complete AI response to history + if (fullResponse) { + const assistantMessage = { role: 'assistant', content: fullResponse }; + chatHistories[currentChatId].push(assistantMessage); + saveChatHistoryToStorage(currentChatId); // Save after getting full response + } else { + // Handle case where stream ended with no content + console.warn("Stream ended without content."); + // Maybe display a generic "No response" message? Not added here. + } + + // 5. Update token usage *after* receiving AI response + await updateTokenUsage(); + + } catch (error) { + console.error('Error during chat completion:', error); + showAlert(`Fehler bei der Kommunikation mit der AI: ${error.message}`, 'error'); + ui.finalizeStreamingMessage(); // Ensure UI is cleaned up on error + // Optionally remove the empty assistant message div if created? + } finally { + ui.enableInput(); // Re-enable input regardless of success/error + } +} + +// --- History & Storage --- +function saveChatHistoryToStorage(chatId) { + if (chatHistories[chatId]) { + try { + localStorage.setItem(`chatHistory-${chatId}`, JSON.stringify(chatHistories[chatId])); + } catch (e) { + console.error("Error saving chat history to localStorage:", e); + showAlert("Fehler beim Speichern des Chat-Verlaufs. Möglicherweise ist der Speicher voll.", "error"); + } + } +} + +function generateChatName(history, chatId) { + // Use the first user message as the name, truncated + const firstUserMessage = history?.find(msg => msg.role === 'user')?.content; + if (firstUserMessage) { + return firstUserMessage.substring(0, 25) + (firstUserMessage.length > 25 ? '...' : ''); + } + // Fallback name + return `Chat ${chatId.slice(-4)}`; +} + +function updateChatNameIfNeeded(chatId) { + // If history only has 1 message (the user one just added), update name + if (chatHistories[chatId] && chatHistories[chatId].length === 1) { + const newName = generateChatName(chatHistories[chatId], chatId); + ui.updateChatNameInList(chatId, newName); + } +} + +// --- Tokenization & Progress --- +async function updateTokenUsage() { + if (!currentChatId || !chatHistories[currentChatId]) { + ui.updateProgressBar(0, maxTokens); // Reset if no chat + return; + } + try { + // Call tokenize API with current history + const tokenInfo = await api.tokenizePrompt(chatHistories[currentChatId], systemPrompt, availableModel); + maxTokens = tokenInfo.max_model_len || maxTokens; // Update max tokens if provided + const currentTokens = tokenInfo.count || 0; + ui.updateProgressBar(currentTokens, maxTokens); + } catch (error) { + console.error("Failed to update token usage:", error); + // Keep previous progress bar state or reset? Resetting might be clearer. + ui.updateProgressBar(0, maxTokens); + } +} \ No newline at end of file diff --git a/client/js/chat_ui.js b/client/js/chat_ui.js new file mode 100644 index 0000000..dcc114f --- /dev/null +++ b/client/js/chat_ui.js @@ -0,0 +1,193 @@ +const chatBox = document.getElementById('chatBox'); +const chatInput = document.getElementById('chatInput'); +const sendButton = document.getElementById('sendButton'); +const chatList = document.getElementById('chatList'); +const progressBar = document.getElementById('progress-bar'); +const usageText = document.getElementById('usage-text'); +const loadingIndicator = document.getElementById('loadingIndicator'); + +let currentAssistantMessageDiv = null; // To hold the streaming message div + +export function displayMessage(text, sender, options = { isMarkdown: false, isStreaming: false }) { + // Sanitize text before inserting, especially if using innerHTML + // For user messages, simple textContent is safer. For Markdown, use DOMPurify. + const sanitizedText = sender === 'user' ? text : (options.isMarkdown ? DOMPurify.sanitize(marked.parse(text)) : text); + + const messageElement = document.createElement('div'); + messageElement.classList.add('chat-message', sender); + + if (sender === 'assistant' && options.isMarkdown) { + messageElement.innerHTML = sanitizedText; // Use sanitized HTML + } else { + messageElement.textContent = sanitizedText; // Safer for plain text + } + + const isScrolledToBottom = chatBox.scrollHeight - chatBox.clientHeight <= chatBox.scrollTop + 5; // Tolerance + + // If starting a new streaming message, create the container + if (sender === 'assistant' && options.isStreaming && !currentAssistantMessageDiv) { + currentAssistantMessageDiv = document.createElement('div'); + currentAssistantMessageDiv.classList.add('chat-message', 'assistant'); + chatBox.appendChild(currentAssistantMessageDiv); + } + + // Update or append message + if (currentAssistantMessageDiv && sender === 'assistant' && options.isStreaming) { + currentAssistantMessageDiv.innerHTML = sanitizedText; // Update streaming div + } else { + chatBox.appendChild(messageElement); // Append regular message or finalize stream + currentAssistantMessageDiv = null; // Reset stream div reference + } + + // Scroll to bottom only if the user was already near the bottom + if (isScrolledToBottom) { + chatBox.scrollTop = chatBox.scrollHeight; + } +} + +// Function to update the streaming message div content incrementally +export function updateStreamingMessage(contentChunk, isMarkdown = true) { + if (!currentAssistantMessageDiv) { + // Create the div if it doesn't exist (e.g., first chunk) + currentAssistantMessageDiv = document.createElement('div'); + currentAssistantMessageDiv.classList.add('chat-message', 'assistant'); + chatBox.appendChild(currentAssistantMessageDiv); + } + + // Append chunk (if plain text) or re-render markdown (simpler for now) + // For true streaming of markdown, need more complex parsing + let currentContent = currentAssistantMessageDiv.dataset.rawContent || ''; + currentContent += contentChunk; + currentAssistantMessageDiv.dataset.rawContent = currentContent; // Store raw text + + if (isMarkdown && window.marked && window.DOMPurify) { + currentAssistantMessageDiv.innerHTML = DOMPurify.sanitize(marked.parse(currentContent)); + } else { + currentAssistantMessageDiv.textContent = currentContent; + } + + // Keep scrolled to bottom during streaming + chatBox.scrollTop = chatBox.scrollHeight; +} + +// Function to finalize the streaming message div +export function finalizeStreamingMessage() { + if (currentAssistantMessageDiv) { + delete currentAssistantMessageDiv.dataset.rawContent; // Clean up temp data + } + currentAssistantMessageDiv = null; +} + + +export function clearChatBox() { + chatBox.innerHTML = ''; + // Optionally add a default message + // displayMessage("Neuer Chat gestartet.", "assistant"); +} + +export function clearChatInput() { + chatInput.value = ''; + autoResizeTextarea(); // Reset height +} + +export function disableInput(isLoading = true) { + chatInput.disabled = true; + sendButton.disabled = true; + chatInput.placeholder = isLoading ? 'Warte auf Antwort...' : 'Schreibe eine Nachricht...'; + if (isLoading && loadingIndicator) loadingIndicator.style.display = 'inline-block'; // Show spinner + if (isLoading) sendButton.style.display = 'none'; // Hide send button +} + +export function enableInput() { + chatInput.disabled = false; + sendButton.disabled = false; + chatInput.placeholder = 'Schreibe eine Nachricht...'; + if (loadingIndicator) loadingIndicator.style.display = 'none'; // Hide spinner + sendButton.style.display = 'flex'; // Show send button + chatInput.focus(); +} + +export function addChatToList(chatData, isActive, loadChatCallback, deleteChatCallback) { + const container = document.createElement('div'); + container.classList.add('chat-session-button-container'); + container.dataset.chatId = chatData.id; // Add ID to container for easier selection + if (isActive) { + container.classList.add('active'); + } + + const button = document.createElement('button'); + button.classList.add('chat-session-button'); + button.textContent = chatData.name || `Chat ${chatData.id.slice(-4)}`; // Use name or part of ID + button.addEventListener('click', () => loadChatCallback(chatData.id)); + + const deleteBtn = document.createElement('button'); + deleteBtn.classList.add('delete-chat-button'); + deleteBtn.setAttribute('aria-label', `Delete chat ${chatData.name || chatData.id}`); + deleteBtn.innerHTML = '🗑️'; // Use trash icon + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); // Prevent chat loading + deleteChatCallback(chatData.id); + }); + + container.appendChild(button); + container.appendChild(deleteBtn); + // Prepend to the top of the list for newest first + chatList.insertBefore(container, chatList.firstChild); +} + +export function removeChatFromList(chatId) { + const chatElement = chatList.querySelector(`.chat-session-button-container[data-chat-id="${chatId}"]`); + if (chatElement) { + chatElement.remove(); + } +} + +export function updateChatNameInList(chatId, name) { + const button = chatList.querySelector(`.chat-session-button-container[data-chat-id="${chatId}"] .chat-session-button`); + const deleteButton = chatList.querySelector(`.chat-session-button-container[data-chat-id="${chatId}"] .delete-chat-button`); + if (button) { + button.textContent = name || `Chat ${chatId.slice(-4)}`; + } + if (deleteButton) { + deleteButton.setAttribute('aria-label', `Delete chat ${name || chatId}`); + } +} + +export function setActiveChatInList(chatId) { + document.querySelectorAll('.chat-session-button-container').forEach(container => { + container.classList.toggle('active', container.dataset.chatId === chatId); + }); +} + +export function updateProgressBar(currentUsage, maxUsage) { + // Ensure values are numbers and maxUsage is positive + currentUsage = Number(currentUsage) || 0; + maxUsage = Number(maxUsage) || 1; // Avoid division by zero + maxUsage = Math.max(1, maxUsage); // Ensure maxUsage is at least 1 + + const percentage = Math.min(100, Math.max(0, (currentUsage / maxUsage) * 100)); // Clamp between 0 and 100 + + if (progressBar) { + progressBar.style.width = `${percentage}%`; + } + if (usageText) { + // Format maxUsage for readability if large? + usageText.textContent = `Token Usage: ${currentUsage} / ${maxUsage}`; + } +} + +// Auto-resize textarea helper +export function autoResizeTextarea() { + chatInput.style.overflowY = 'hidden'; // Prevent scrollbar flash + chatInput.style.height = 'auto'; + const scrollHeight = chatInput.scrollHeight; + const maxHeight = parseInt(window.getComputedStyle(chatInput).maxHeight, 10); + + if (maxHeight > 0 && scrollHeight > maxHeight) { + chatInput.style.height = `${maxHeight}px`; + chatInput.style.overflowY = 'auto'; + } else { + chatInput.style.height = `${scrollHeight}px`; + chatInput.style.overflowY = 'hidden'; + } +} \ No newline at end of file diff --git a/client/js/config.js b/client/js/config.js new file mode 100644 index 0000000..40ac3e1 --- /dev/null +++ b/client/js/config.js @@ -0,0 +1,3 @@ +// Central configuration +export const BASE_API_URL = 'http://localhost:8015'; // Your backend URL +export const API_KEY = 'YOUR_API_KEY'; // Replace with your actual key or use a more secure method \ No newline at end of file diff --git a/client/js/main.js b/client/js/main.js index 815ce46..e3fb8b8 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -1,175 +1,381 @@ -import {showAlert, showConfirm} from './shared_functions.js'; +import { showAlert, showConfirm, sanitizeInput } from './shared_functions.js'; +import * as api from './api_service.js'; +import { initializeChat, createNewChat, handleSendMessage } from './chat_manager.js'; // Only import needed functions directly used by main +import * as ui from './chat_ui.js'; // Need UI functions -// IP-Adresse -const baseUrl = 'http://localhost:8015'; +// --- DOM Elements --- +// Moved some element selections inside setup functions to ensure they exist +const bodyElement = document.body; +console.log("main: ") +console.log(bodyElement) +let themeToggleButton = null; // Initialize as null +let logoutBtn = null; // Initialize as null -// Funktion zum Laden der Benutzerdaten für die normale Webseite -async function loadUserData() { - const token = localStorage.getItem('token'); - if (!token) { - showAlert('Nicht autorisiert. Bitte einloggen.'); - window.location.href = 'index.html'; - return; +// --- Initialization --- +document.addEventListener('DOMContentLoaded', () => { + console.log("DOM Content Loaded. Body ID:", bodyElement.id); // Log body ID + + // Common elements present on all/most pages + themeToggleButton = document.getElementById('themeToggleBtn'); + logoutBtn = document.getElementById('logoutBtn'); + + loadThemePreference(); // Load theme early + + // Add common listeners if elements exist + if (themeToggleButton) { + console.log("Attaching theme listener"); + themeToggleButton.addEventListener('click', toggleTheme); + } else { + console.warn("Theme toggle button not found"); + } + if (logoutBtn) { + console.log("Attaching logout listener"); + logoutBtn.addEventListener('click', logout); + } else { + console.warn("Logout button not found"); } - try { - const response = await fetch(`${baseUrl}/api/auth/me`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }); - - console.log('Response status:', response.status); - - if (!response.ok) { - throw new Error('Fehler beim Abrufen der Benutzerdaten'); - } - - const data = await response.json(); - - document.getElementById('username').textContent = data.user; - document.getElementById('isAdmin').textContent = data.isAdmin ? 'Ja' : 'Nein'; - - // Überprüfen, ob der Benutzer Admin ist - if (data.isAdmin) { - const adminBtn = document.getElementById('adminPermissionsBtn'); - adminBtn.style.display = 'inline-block'; // Button anzeigen - adminBtn.addEventListener('click', () => { - window.location.href = 'admin.html'; // Bei Klick weiterleiten + // Page specific initialization with error handling for the setup promises + if (bodyElement.id === 'chat-page') { + console.log("Running Chat Page Setup"); + setupChatPage() + .catch(error => { + console.error("Error during Chat Page Setup:", error); + showAlert("Ein kritischer Fehler ist beim Laden der Chat-Seite aufgetreten.", "error"); }); - } - - } catch (error) { - console.error(error); - showAlert('Sitzung abgelaufen. Bitte erneut einloggen.'); - localStorage.removeItem('token'); - window.location.href = 'index.html'; + } else if (bodyElement.id === 'admin-page') { + console.log("Running Admin Page Setup"); + setupAdminPage() + .catch(error => { + // This catch is now primarily for unexpected errors *within* setupAdminPage's try block + // as checkAdminStatus errors should be handled more specifically inside. + console.error("Unhandled error during Admin Page Setup:", error); + if (!error.message?.includes("handled")) { // Avoid double alerts if handled internally + showAlert("Ein unerwarteter Fehler ist beim Laden der Admin-Seite aufgetreten.", "error"); + } + }); + } else if (bodyElement.id === 'login-page') { + console.log("Login/Register page setup handled by auth.js"); + } else { + console.warn("No specific page setup for body ID:", bodyElement.id); } -} - -// Funktion zum Abmelden -document.getElementById('logoutBtn').addEventListener('click', () => { - localStorage.removeItem('token'); - window.location.href = 'index.html'; }); -function toWelcome() { - location.href = 'welcome.html'; -} - -async function loadAdminData() { - const token = localStorage.getItem('token'); - if (!token) { - showAlert('Nicht autorisiert. Bitte einloggen.'); - window.location.href = 'index.html'; +// --- Page Setup Functions --- +async function setupChatPage() { + console.log("setupChatPage started"); + if (!localStorage.getItem('token')) { + redirectToLogin('Kein Token gefunden. Bitte einloggen.'); return; } + // Select elements specific to chat page *inside* setup + const adminPermissionsBtn = document.getElementById('adminPermissionsBtn'); + const newChatButton = document.getElementById('newChatButton'); + const chatInput = document.getElementById('chatInput'); + const sendButton = document.getElementById('sendButton'); + + // Load user data first + await loadUserDataForChat(); // This function now handles UI updates internally + + // Setup chat specific event listeners *after* confirming elements exist + if (newChatButton) { + console.log("Attaching new chat listener"); + newChatButton.addEventListener('click', createNewChat); + } else { + console.error("New Chat button not found!"); + } + + if (sendButton) { + console.log("Attaching send listener"); + sendButton.addEventListener('click', processUserInput); + } else { + console.error("Send button not found!"); + } + + if (chatInput) { + console.log("Attaching input listeners"); + chatInput.addEventListener('keydown', handleChatInputKeydown); + chatInput.addEventListener('input', ui.autoResizeTextarea); + // Initial resize + ui.autoResizeTextarea(); + } else { + console.error("Chat input not found!"); + } + + if (adminPermissionsBtn) { + console.log("Attaching admin button listener"); + adminPermissionsBtn.addEventListener('click', () => { window.location.href = 'admin.html'; }); + } else { + // This might be expected if the user is not admin, handled by loadUserDataForChat + // console.warn("Admin permissions button not found (may be expected)"); + } + + // Initialize chat manager AFTER setting up listeners that might use its functions try { - const response = await fetch(`${baseUrl}/api/auth/admin`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - showAlert('Zugriff verweigert. Sie sind kein Admin.'); - window.location.href = 'welcome.html'; - return; - } - - const data = await response.json(); - console.log(data.message); // "Willkommen, Admin!" - } catch (error) { - console.error(error); - showAlert('Sitzung abgelaufen. Bitte erneut einloggen.'); - localStorage.removeItem('token'); - window.location.href = 'index.html'; + console.log("Initializing Chat Manager..."); + await initializeChat(); + console.log("Chat Manager Initialized."); + } catch(error) { + console.error("Error initializing chat manager:", error); + showAlert("Fehler beim Initialisieren des Chats.", "error"); } } -// Funktion zum Löschen eines Benutzers -async function deleteUser(userId, isAdmin) { - - // If the user is an admin, show a warning confirmation - if (isAdmin) { - const userConfirmed = await showConfirm('Warnung: Möchten Sie wirklich einen Administrator löschen? Dies kann nicht rückgängig gemacht werden.', 'error'); - if (!userConfirmed) { - return; // If the user cancels, do nothing - } +async function setupAdminPage() { + console.log("setupAdminPage started"); + if (!localStorage.getItem('token')) { + redirectToLogin('Kein Token gefunden. Bitte einloggen.'); + return; } - const token = localStorage.getItem('token'); - try { - const response = await fetch(`${baseUrl}/api/auth/user/${userId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); + // Select elements specific to admin page + const loadUserListBtn = document.getElementById('loadUserListBtn'); + const welcomeButton = document.getElementById('welcomeButton'); + const userListDiv = document.getElementById('user-list'); // Also select user list container - if (response.ok) { - await loadUserList(); // Liste aktualisieren + if (!userListDiv) { + console.error("User list container ('user-list') not found on admin page!"); + // Optionally show an error message in the main area if the container is missing + } + + + try { + // Check admin status first. If this fails, it will throw an error. + // checkAdminStatus handles redirection/alerts for 401/403. + await checkAdminStatus(); + console.log("Admin status confirmed. Proceeding with admin setup."); + + // ---- If admin check passes, THEN setup listeners and load list ---- + + // Setup 'Reload List' button listener + if (loadUserListBtn) { + console.log("Attaching load user list listener"); + loadUserListBtn.addEventListener('click', () => { + console.log("Reload list button clicked."); + loadUserList(); // Call loadUserList on click + }); } else { - showAlert('Fehler beim Löschen des Benutzers'); + console.error("Load user list button ('loadUserListBtn') not found!"); + } + + // Setup 'Back to Chat' button listener + if (welcomeButton) { + console.log("Attaching welcome button listener"); + welcomeButton.addEventListener('click', () => { + console.log("Welcome button clicked, redirecting..."); + window.location.href = 'welcome.html'; + }); + } else { + console.error("Welcome (back to chat) button ('welcomeButton') not found!"); + } + + // Load list initially ONLY if admin check succeeded + console.log("Initiating initial user list load."); + await loadUserList(); // Load the list data + console.log("Initial user list load attempt finished."); + + } catch (error) { + // This catch block now primarily handles errors from checkAdminStatus + // or potentially the initial loadUserList call if it throws unexpectedly. + console.error("SetupAdminPage failed during admin check or initial load:", error); + // Most user-facing errors (like redirection or alerts for auth/network issues) + // should have been handled within checkAdminStatus or loadUserList's call to handleApiError. + // We might not need to show another alert here unless it's a truly unexpected setup problem. + // Mark the error as handled to avoid potential double alerts from the outer catch block. + error.message += " (handled in setupAdminPage)"; + } +} + +// --- Event Handlers --- +function handleChatInputKeydown(event) { + // console.log("Keydown:", event.key); // Debugging keystrokes + if (event.key === 'Enter' && !event.shiftKey) { + console.log("Enter pressed"); // Debugging Enter key + event.preventDefault(); + processUserInput(); + } +} + +function processUserInput() { + const chatInput = document.getElementById('chatInput'); // Re-select or ensure it's available + if (!chatInput) { + console.error("Cannot process user input: chatInput not found."); + return; + } + const messageText = sanitizeInput(chatInput.value.trim()); + console.log("Processing user input:", messageText); // Debugging input processing + if (messageText) { + handleSendMessage(messageText) // Call chat manager function + .then(() => { + console.log("Message handled by manager."); + ui.clearChatInput(); + }) + .catch(error => { + console.error("Error sending message via manager:", error); + // UI should already be enabled by chat_manager's finally block + }); + } else { + console.log("Input is empty, not sending."); + } +} + + +// --- User Data and Auth --- +async function loadUserDataForChat() { + const usernameSpan = document.getElementById('username'); + const isAdminSpan = document.getElementById('isAdmin'); + const adminPermissionsBtn = document.getElementById('adminPermissionsBtn'); + + try { + console.log("Loading user data for chat page..."); + const data = await api.getUserData(); + console.log("User data received:", data); + if (usernameSpan) usernameSpan.textContent = data.user || 'Unbekannt'; + if (isAdminSpan) isAdminSpan.textContent = data.isAdmin ? 'Ja' : 'Nein'; + if (adminPermissionsBtn) { + // Control display based on fetched data + adminPermissionsBtn.style.display = data.isAdmin ? 'inline-block' : 'none'; + console.log("Admin button display set to:", adminPermissionsBtn.style.display); } } catch (error) { - console.error('Fehler beim Löschen des Benutzers:', error); + console.error("Failed to load user data:", error); + // Display error state in UI + if (usernameSpan) usernameSpan.textContent = 'Fehler'; + if (isAdminSpan) isAdminSpan.textContent = 'Fehler'; + handleApiError(error, 'Benutzerdaten laden'); // Show alert, potentially redirect + } +} + +// --- Definition of checkAdminStatus --- Moved here to ensure it's defined before use +async function checkAdminStatus() { + console.log("Checking admin status..."); + try { + // Assuming api.getAdminData() makes the /api/auth/admin call + // and throws an error (e.g., 401, 403) if the user is not an admin. + await api.getAdminData(); + console.log("Admin access confirmed."); + // No need to return true, successful execution implies admin status + } catch (error) { + console.warn("Admin status check failed:", error.status, error.message); + if (error.status === 403 || error.status === 401) { // Forbidden or Unauthorized + showAlert('Zugriff verweigert. Nur Admins erlaubt.', 'error'); + window.location.href = 'welcome.html'; // Redirect non-admins immediately + } else { + // Handle other errors (e.g., network error) + handleApiError(error, 'Admin-Status prüfen'); + } + // IMPORTANT: Re-throw the error AFTER handling redirection/alert + // This signals to the caller (setupAdminPage) that the check failed. + throw error; } } async function loadUserList() { - const token = localStorage.getItem('token'); - const response = await fetch(`${baseUrl}/api/auth/users`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + const userListDiv = document.getElementById('user-list'); // Select inside function + if (!userListDiv) { + console.error("User list container not found!"); + return; + } + userListDiv.innerHTML = '

Lade Benutzer...

'; // Loading state - if (response.ok) { - const users = await response.json(); - const userList = document.getElementById('user-list'); - userList.innerHTML = ''; // Liste leeren + try { + const users = await api.getAllUsers(); + userListDiv.innerHTML = ''; // Clear loading/previous list - users.forEach(user => { - const userDiv = document.createElement('div'); - userDiv.style.display = 'flex'; - userDiv.style.alignItems = 'center'; - userDiv.setAttribute('id', `user-list-element-${user._id}`); // Give each div a unique ID - userDiv.innerHTML = ` -

Benutzername: ${user.username}

-

Email: ${user.email}

-

Admin: ${user.isAdmin}

- - `; - userList.appendChild(userDiv); + if (users && users.length > 0) { + users.forEach(user => { + const userDiv = document.createElement('div'); + userDiv.classList.add('user-list-item'); + userDiv.dataset.userId = user._id; - // Add event listener to the specific delete button - const deleteButton = userDiv.querySelector(".button-delete"); - deleteButton.addEventListener("click", function() { - deleteUser(user._id, user.isAdmin); + const usernameP = document.createElement('p'); + usernameP.innerHTML = `User: ${sanitizeInput(user.username)}`; + + const emailP = document.createElement('p'); + emailP.innerHTML = `Email: ${sanitizeInput(user.email)}`; + + const adminP = document.createElement('p'); + adminP.innerHTML = `Admin: ${user.isAdmin ? 'Ja' : 'Nein'}`; + + const deleteButton = document.createElement('button'); + deleteButton.classList.add('button-delete', 'header-btn'); + deleteButton.style.backgroundColor = 'var(--error-color)'; + deleteButton.textContent = 'Löschen'; + deleteButton.addEventListener('click', () => handleDeleteUser(user._id, user.isAdmin, user.username)); // Pass userListDiv here if needed + + userDiv.append(usernameP, emailP, adminP, deleteButton); + userListDiv.appendChild(userDiv); }); - }); - } else { - showAlert('Fehler beim Laden der Benutzerliste'); + } else { + userListDiv.innerHTML = '

Keine Benutzer gefunden.

'; + } + + } catch (error) { + handleApiError(error, 'Benutzerliste laden'); + userListDiv.innerHTML = '

Fehler beim Laden der Benutzerliste.

'; } } -// Überprüfen, ob wir uns auf der Admin-Seite befinden, und dann den Event-Listener hinzufügen -const loadUserListBtn = document.getElementById('loadUserListBtn'); -if (loadUserListBtn) { - loadUserListBtn.addEventListener('click', loadUserList); // Benutzerliste laden, wenn der Button geklickt wird +async function handleDeleteUser(userId, isAdmin, username) { + const userListDiv = document.getElementById('user-list'); // Select inside function if needed + const message = `Möchten Sie den Benutzer "${sanitizeInput(username)}" wirklich löschen? ${isAdmin ? ' Dies ist ein Admin!' : ''}`; + const confirmed = await showConfirm(message, 'error'); + + if (confirmed) { + try { + await api.deleteUserById(userId); + showAlert(`Benutzer "${sanitizeInput(username)}" erfolgreich gelöscht.`, 'success'); + // Remove user from list visually immediately + const userDiv = userListDiv?.querySelector(`.user-list-item[data-user-id="${userId}"]`); + if (userDiv) userDiv.remove(); + // Or reload the whole list: await loadUserList(); + } catch (error) { + handleApiError(error, 'Benutzer löschen'); + } + } } - - -// Abhängig von der Seite entweder Admin- oder Benutzerdaten laden -if (window.location.pathname.includes('admin.html')) { - // Funktion um auf die Willkommensseite zurückzukommen - document.getElementById("welcomeButton").addEventListener("click", toWelcome); - await loadAdminData(); - await loadUserList(); -} else if (window.location.pathname.includes('welcome.html')) { - await loadUserData(); +// --- Theme Toggling --- +function toggleTheme() { + const isDarkMode = bodyElement.classList.toggle('dark-theme'); + if (themeToggleButton) { + themeToggleButton.textContent = isDarkMode ? '🌙' : '☀️'; + } + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); } +function loadThemePreference() { + const savedTheme = localStorage.getItem('theme'); + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = savedTheme === 'dark' || (!savedTheme && prefersDark); + + bodyElement.classList.toggle('dark-theme', isDark); // Set class based on isDark boolean + if (themeToggleButton) { // Check if button exists before setting text + themeToggleButton.textContent = isDark ? '🌙' : '☀️'; + } +} + +// --- Generic Error Handling --- +function handleApiError(error, context) { + console.error(`API Error during ${context}:`, error); + if (error.status === 401 || error.status === 403) { + redirectToLogin(`Sitzung abgelaufen oder nicht autorisiert (${context}). Bitte erneut einloggen.`); + } else if (error.message.includes('Network Error')) { + showAlert(`Netzwerkfehler bei "${context}". Server nicht erreichbar.`, 'error'); + } else { + // Show specific message from API if available, otherwise generic + showAlert(`Fehler bei "${context}": ${error.data?.message || error.message || 'Unbekannter Fehler'}`, 'error'); + } +} + +// --- Utility Functions --- (logout, redirectToLogin same as before) +function logout() { + localStorage.removeItem('token'); + window.location.href = 'index.html'; +} + +function redirectToLogin(message) { + if (message) showAlert(message, 'error'); + setTimeout(() => { window.location.href = 'index.html'; }, 1500); +} \ No newline at end of file diff --git a/client/js/shared_functions.js b/client/js/shared_functions.js index 3864219..d02ec83 100644 --- a/client/js/shared_functions.js +++ b/client/js/shared_functions.js @@ -1,51 +1,59 @@ -// Alert Box // Function to show the custom alert with a message and type -export function showAlert(message, type = 'error') { +export function showAlert(message, type = 'info') { // Default to info or error? const alertBox = document.getElementById('custom-alert'); + if (!alertBox) return; // Guard clause + const alertMessage = document.getElementById('alert-message'); const closeButton = document.getElementById('close-alert'); - alertMessage.textContent = message; // Set the message to display - alertBox.style.display = 'block'; // Show the alert box + if(alertMessage) alertMessage.textContent = message; + alertBox.className = 'custom-alert'; // Reset classes first + alertBox.classList.add(type); // Add type class ('success', 'error', 'info') + alertBox.style.display = 'block'; - // Reset previous styles - alertBox.classList.remove('success', 'error'); - alertBox.classList.add(type); // Add the appropriate class (success/error) - - // Add event listener to close the alert box - closeButton.addEventListener('click', () => { - alertBox.style.display = 'none'; // Hide the alert box when the button is clicked - }); + // Ensure listener is only added once or managed properly + // A simple approach: remove previous before adding new + const newCloseButton = closeButton.cloneNode(true); // Clone to remove old listeners + closeButton.parentNode.replaceChild(newCloseButton, closeButton); + newCloseButton.addEventListener('click', () => { + alertBox.style.display = 'none'; + }, { once: true }); // Automatically remove listener after one click } +// Function to show a confirmation dialog export function showConfirm(message, type = 'error') { return new Promise((resolve) => { - const alertBox = document.getElementById('custom-alert-confirm'); - const alertMessage = document.getElementById('alert-message-confirm'); + const confirmBox = document.getElementById('custom-alert-confirm'); + if (!confirmBox) { resolve(false); return; } // Guard clause + + const confirmMessage = document.getElementById('alert-message-confirm'); const closeButton = document.getElementById('close-alert-confirm'); const confirmButton = document.getElementById('confirm-alert-confirm'); - alertMessage.textContent = message; // Set the message to display - alertBox.style.display = 'block'; // Show the alert box + if(confirmMessage) confirmMessage.textContent = message; + confirmBox.className = 'custom-alert-confirm'; // Reset classes + confirmBox.classList.add(type); + confirmBox.style.display = 'block'; - // Reset previous styles - alertBox.classList.remove('success', 'error'); - alertBox.classList.add(type); // Add the appropriate class (success/error) + // Use cloning to ensure listeners are fresh + const newConfirmButton = confirmButton.cloneNode(true); + confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton); + newConfirmButton.addEventListener('click', () => { + confirmBox.style.display = 'none'; + resolve(true); + }, { once: true }); - // Display the confirm button - confirmButton.style.display = 'inline-block'; - closeButton.style.display = 'inline-block'; - - // When "Confirm" button is clicked - confirmButton.addEventListener('click', () => { - alertBox.style.display = 'none'; // Hide the alert box - resolve(true); // Resolve the promise as true (user confirmed) - }); - - // When "Close" button is clicked - closeButton.addEventListener('click', () => { - alertBox.style.display = 'none'; // Hide the alert box - resolve(false); // Resolve the promise as false (user canceled) - }); + const newCloseButton = closeButton.cloneNode(true); + closeButton.parentNode.replaceChild(newCloseButton, closeButton); + newCloseButton.addEventListener('click', () => { + confirmBox.style.display = 'none'; + resolve(false); + }, { once: true }); }); +} + +// Basic input sanitizer (replace HTML tags) +export function sanitizeInput(str) { + if (!str) return ''; + return str.replace(//g, ">"); } \ No newline at end of file diff --git a/client/welcome.html b/client/welcome.html index 8872f47..f850320 100644 --- a/client/welcome.html +++ b/client/welcome.html @@ -3,63 +3,86 @@ - Willkommen + AI Chat Interface + + + + + + + - - -
-
- - + + + +
+ + -
- -
-

Ein simples Chat-Interface.

+
+ + + +
+
- - -
- - -
- - +
- - + - - -
+ +
+
-
-
-
-

0 / 70144 used

+
+

Token Usage: 0 / 0

-
- + +
+
Wähle einen Chat oder starte einen neuen.
+
- - - + + +
-
- +
+ + +
+

+ + + + - - + + + + - + \ No newline at end of file