diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a49d851 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.pot +*.mo +.mr.developer.cfg +.project +.pydevproject + +# Node.js +node_modules/ +npm-debug.log +yarn-error.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Project-specific logs +server.log +mcp_server.log +*.log + +# PID files +*.pid + +# Backup files +*.bak +*_jsfix + +# Corrupted/incomplete files +main.js.corrupted + +# Environment and credentials +.env +.env.* +*.pem +*.key +credentials* +secrets* +*.secret + +# Temporary ComfyUI outputs +outputs/ +temp/ +tmp/ +comfyui/outputs/ diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..bf4a1e8 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,299 @@ +# LM Studio Web - Journal de Développement + +**Date :** 2026-03-26 / 2026-03-27 +**Statut :** Fonctionnel avec limitations identifiées +**Prochaine étape :** Détection intelligente des modèles vision vs texte + +--- + +## 🎯 Objectif Principal + +Interface web moderne pour chatter avec : +- LM Studio (local) +- Ollama (local) +- OpenRouter (cloud) +- + Génération d'images via ComfyUI (`/genimg`) + +--- + +## ✅ Ce Qui Fonctionne (Acquis) + +### Interface Utilisateur + +- [x] Sélection de serveur avec presets (Ollama, LM Studio, OpenRouter, Custom) +- [x] Champ API key conditionnel (visible pour OpenRouter) +- [x] Dropdown de modèles (rempli automatiquement) +- [x] Chat avec streaming de réponses +- [x] Système de conversations (sidebar, New Chat, Delete, Switch) +- [x] Persistance localStorage (system prompt, sélection serveur, API key) +- [x] Markdown rendering (marked.js) + Highlight.js (code) +- [x] LaTeX support (MathJax) +- [x] Upload d'image locale (bouton "+" image) +- [x] Lightbox (click pour zoomer images) +- [x] Boutons "Copy" sur blocs de code +- [x] Spinner "Thinking..." pendant attente réponse +- [x] Responsive design (mobile-friendly) +- [x] Thème visuel purple moderne + +### Backend & Intégration + +- [x] Serveur web Python (port 8084) sert fichiers statiques +- [x] Serveur MCP FastAPI (port 8085) → ComfyUI +- [x] Endpoint `/generate-image` (POST) reçoit prompts, envoie à ComfyUI, polling, retourne image_url +- [x] Randomisation des seeds dans le workflow ComfyUI (évite cache) +- [x] Vérification ComfyUI au démarrage (`./start.sh`) +- [x] Logs serveurs dans fichiers (`server.log`, `mcp_server.log`) +- [x] Scripts d'arrêt/propre (`stop.sh`) +- [x] CORS configuré (frontend:8084 → backend:8085) + +### Commandes + +- [x] `/genimg ` → génère image via ComfyUI +- [x] Génération d'image fonctionne (prompt → ComfyUI → image dans chat) +- [x] Lightbox clique → zoom + +--- + +## 🐛 Problèmes Rencontrés & Solutions + +| Problème | Cause | Solution | +|----------|-------|----------| +| **CORS blocked** | Fichiers ouverts via `file://` | Serveur Python sur port 8084 + CORS middleware dans MCP | +| **`Identifier already declared`** | `main.js` chargé deux fois + code inline | Nettoyé HTML: un seul ` + + + + + + + + +
+
+ + + + + + +
+
Disconnected
+
+ +
+ + +
+ +
+
+
+ + +
+ + +
+
+
+
+ +
Delete Chat
+ + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..9aa69a3 --- /dev/null +++ b/main.js @@ -0,0 +1,669 @@ +// ============================================ +// LM Studio Web Chat — avec génération d'image +// ============================================ + +// Configuration +const MCP_SERVER_URL = 'http://localhost:8085'; // Backend FastAPI pour ComfyUI + +// Presets serveurs +const SERVER_PRESETS = { + ollama: 'http://localhost:11434', + lmstudio: 'http://localhost:1234', + openrouter: 'https://openrouter.ai/api/v1' +}; + +// Détection de commande /genimg +const GENIMG_PATTERN = /^\/genimg\s+(.+)$/i; + +// Variables d'état +let isConnected = false; +let currentModel = ''; +let pendingImage = null; +let systemPrompt = ''; + +// Chat management +let chats = []; +let currentChat = null; + +// Définition des éléments DOM +const chatContainer = document.getElementById('chat-container'); +const userInput = document.getElementById('user-input'); +const serverTypeSelect = document.getElementById('server-type-select'); +const customUrlInput = document.getElementById('custom-url'); +const apiKeyInput = document.getElementById('api-key'); +const serverUrlInput = document.getElementById('server-url-input'); +const connectButton = document.getElementById('connect-button'); +const connectionStatus = document.getElementById('connection-status'); +const sendButton = document.getElementById('send-button'); +const newChatButton = document.getElementById('new-chat-button'); +const toggleSidebarButton = document.getElementById('toggle-sidebar'); +const chatSidebar = document.getElementById('chat-sidebar'); +const chatList = document.getElementById('chat-list'); +const contextMenu = document.getElementById('context-menu'); +const modelSelect = document.getElementById('model-select'); +const uploadButton = document.getElementById('upload-button'); +const imageUpload = document.getElementById('image-upload'); +const imagePreview = document.getElementById('image-preview'); +const systemPromptInput = document.getElementById('system-prompt-input'); +const resetSystemPromptButton = document.getElementById('reset-system-prompt'); + +// ============================================ +// Initialisation System Prompt +// ============================================ +function initSystemPrompt() { + const savedPrompt = localStorage.getItem('lmstudio-system-prompt'); + const defaultPrompt = "You are an intelligent assistant. You always provide well-reasoned answers that are both correct and helpful."; + systemPrompt = savedPrompt || defaultPrompt; + systemPromptInput.value = systemPrompt; +} + +systemPromptInput.addEventListener('input', () => { + systemPrompt = systemPromptInput.value; + localStorage.setItem('lmstudio-system-prompt', systemPrompt); +}); + +resetSystemPromptButton.addEventListener('click', () => { + const defaultPrompt = "You are an intelligent assistant. You always provide well-reasoned answers that are both correct and helpful."; + systemPrompt = defaultPrompt; + systemPromptInput.value = systemPrompt; + localStorage.setItem('lmstudio-system-prompt', systemPrompt); +}); + +// ============================================ +// Sélection de Serveur et API Key +// ============================================ +function setupServerSelection() { + const savedServerType = localStorage.getItem('server-type') || 'ollama'; + serverTypeSelect.value = savedServerType; + updateServerUI(); + + serverTypeSelect.addEventListener('change', () => { + localStorage.setItem('server-type', serverTypeSelect.value); + updateServerUI(); + if (serverTypeSelect.value && SERVER_PRESETS[serverTypeSelect.value]) { + serverUrlInput.value = SERVER_PRESETS[serverTypeSelect.value]; + } + }); + + customUrlInput.addEventListener('input', () => { + if (serverTypeSelect.value === 'custom') { + serverUrlInput.value = customUrlInput.value.trim(); + } + }); + + apiKeyInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + userInput.focus(); + } + }); + + apiKeyInput.addEventListener('input', () => { + localStorage.setItem('api-key', apiKeyInput.value); + }); + + const savedApiKey = localStorage.getItem('api-key'); + if (savedApiKey) { + apiKeyInput.value = savedApiKey; + } +} + +function updateServerUI() { + const type = serverTypeSelect.value; + if (!type) { + customUrlInput.style.display = 'none'; + apiKeyInput.style.display = 'none'; + serverUrlInput.value = ''; + } else if (type === 'custom') { + customUrlInput.style.display = 'block'; + apiKeyInput.style.display = 'block'; + serverUrlInput.value = customUrlInput.value.trim(); + } else { + const url = SERVER_PRESETS[type]; + serverUrlInput.value = url; + customUrlInput.style.display = 'none'; + apiKeyInput.style.display = (type === 'openrouter') ? 'block' : 'none'; + } +} + +// ============================================ +// Connexion au Serveur LLM +// ============================================ +async function connectToServer() { + const serverUrl = serverUrlInput.value.trim(); + if (!serverUrl) { + updateConnectionStatus('Please enter a valid server address', false); + return; + } + const apiKey = apiKeyInput.value.trim(); + try { + updateConnectionStatus('Connecting...', false); + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + const response = await fetch(`${serverUrl}/v1/models`, { + method: 'GET', + headers: headers + }); + if (!response.ok) throw new Error('Server response was not ok'); + const data = await response.json(); + if (data && data.data && data.data.length > 0) { + modelSelect.innerHTML = ""; + data.data.forEach(model => { + const option = document.createElement('option'); + option.value = model.id; + option.textContent = model.id; + modelSelect.appendChild(option); + }); + modelSelect.disabled = false; + // S'assurer qu'une option est sélectionnée + if (modelSelect.options.length > 0) { + modelSelect.selectedIndex = 0; + } + currentModel = modelSelect.value; + isConnected = true; + updateConnectionStatus('Connected', true); + userInput.disabled = false; + sendButton.disabled = false; + if (!currentChat) createNewChat(); + const serverLabel = serverTypeSelect.value ? ` ${serverTypeSelect.options[serverTypeSelect.selectedIndex].text}` : ''; + addMessage(`Connected to${serverLabel}. You can start chatting now!`, false, null, false); + } else { + throw new Error('No models available'); + } + } catch (error) { + console.error('Error:', error); + updateConnectionStatus('Failed to connect', false); + addMessage('Error: Unable to connect to the server. Please check the address and try again.', false); + } +} + +function updateConnectionStatus(message, connected) { + connectionStatus.textContent = message; + connectionStatus.style.color = connected ? 'var(--accent-color)' : '#f44336'; + connectButton.textContent = connected ? 'Disconnect' : 'Connect'; + customUrlInput.disabled = connected; + apiKeyInput.disabled = connected; + serverTypeSelect.disabled = connected; + userInput.disabled = !connected; + sendButton.disabled = !connected; +} + +// ============================================ +// Gestion des Chats +// ============================================ +function createNewChat() { + const chatId = Date.now(); + const newChat = { id: chatId, name: `Conversation ${chats.length + 1}`, messages: [] }; + chats.push(newChat); + currentChat = newChat; + updateChatList(); + chatContainer.innerHTML = ''; +} + +function updateChatList() { + chatList.innerHTML = ''; + chats.forEach(chat => { + const li = document.createElement('li'); + li.textContent = chat.name; + li.dataset.chatId = chat.id; + if (currentChat && chat.id === currentChat.id) li.classList.add('active'); + li.addEventListener('click', () => { + if (currentChat && chat.id === currentChat.id) return; + currentChat = chat; + loadChat(chat); + updateChatList(); + }); + li.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showContextMenu(e.pageX, e.pageY, chat.id); + }); + chatList.appendChild(li); + }); +} + +function loadChat(chat) { + chatContainer.innerHTML = ''; + chat.messages.forEach(message => { + addMessage(message.content, message.isUser, message.metrics, false); + }); +} + +function showContextMenu(x, y, chatId) { + contextMenu.style.left = x + "px"; + contextMenu.style.top = y + "px"; + contextMenu.style.display = "block"; + contextMenu.onclick = () => { + deleteChat(chatId); + hideContextMenu(); + }; +} +function hideContextMenu() { contextMenu.style.display = "none"; } +document.addEventListener('click', () => { if (contextMenu.style.display === "block") hideContextMenu(); }); + +function deleteChat(chatId) { + chats = chats.filter(c => c.id != chatId); + if (currentChat && currentChat.id == chatId) { + currentChat = chats.length > 0 ? chats[0] : null; + if (!currentChat) createNewChat(); + else loadChat(currentChat); + } + updateChatList(); +} + +// ============================================ +// Ejection de Modèle +// ============================================ +async function ejectCurrentModel(oldModel) { + const serverUrl = serverUrlInput.value.trim(); + const apiKey = apiKeyInput.value.trim(); + try { + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + await fetch(`${serverUrl}/v1/model/eject`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ model: oldModel }) + }); + console.log(`Model ${oldModel} ejected.`); + } catch (error) { + console.error("Error ejecting model:", error); + } +} + +modelSelect.addEventListener('change', async (e) => { + const newModel = e.target.value; + if (currentModel && currentModel !== newModel) { + modelSelect.disabled = true; + await ejectCurrentModel(currentModel); + } + currentModel = newModel; + modelSelect.disabled = false; +}); + +// ============================================ +// Construction de l'Historique +// ============================================ +function buildConversationHistory() { + let effectiveSystemPrompt = systemPrompt; + if (currentChat.messages.some(msg => msg.isImage) && !systemPrompt.trim()) { + effectiveSystemPrompt = "You are an AI assistant that analyzes images."; + } else if (!systemPrompt.trim()) { + effectiveSystemPrompt = "You are an intelligent assistant. You always provide well-reasoned answers that are both correct and helpful."; + } + const history = [{ role: 'system', content: effectiveSystemPrompt }]; + currentChat.messages.forEach(msg => { + if (msg.isImage) { + // Pour les modèles ne supportant pas la vision, on envoie seulement le texte + // Le modèle ne verra pas l'image mais l'historique reste valide + const textContent = msg.text || "Image generated."; + history.push({ + role: msg.isUser ? 'user' : 'assistant', + content: textContent + }); + } else { + history.push({ role: msg.isUser ? 'user' : 'assistant', content: msg.content }); + } + }); + return history; +} + +// ============================================ +// Ajout de Messages (texte & image) +// ============================================ +function addMessage(content, isUser, metrics = null, store = true) { + const messageDiv = document.createElement('div'); + messageDiv.classList.add('message', isUser ? 'user-message' : 'assistant-message'); + + const headerDiv = document.createElement('div'); + headerDiv.classList.add('message-header'); + headerDiv.textContent = isUser ? 'You' : 'Assistant'; + messageDiv.appendChild(headerDiv); + + if (!isUser && currentModel) { + const modelDiv = document.createElement('div'); + modelDiv.classList.add('message-model'); + modelDiv.textContent = currentModel; + messageDiv.appendChild(modelDiv); + } + + const contentDiv = document.createElement('div'); + contentDiv.classList.add('message-content'); + contentDiv.innerHTML = marked.parse(content); + messageDiv.appendChild(contentDiv); + + if (metrics) { + const metricsDiv = document.createElement('div'); + metricsDiv.classList.add('message-metrics'); + metricsDiv.textContent = metrics; + messageDiv.appendChild(metricsDiv); + } + + chatContainer.appendChild(messageDiv); + chatContainer.scrollTop = chatContainer.scrollHeight; + + messageDiv.querySelectorAll('pre code').forEach(block => { + hljs.highlightElement(block); + }); + + if (typeof MathJax !== 'undefined') { + MathJax.typesetPromise([messageDiv]).catch(err => console.error('MathJax typeset failed:', err)); + } + + attachCopyButtons(messageDiv); + + if (store && currentChat) { + currentChat.messages.push({ content, isUser, metrics, isImage: false }); + } +} + +// ============================================ +// Boutons de Copie pour Code Blocks +// ============================================ +function attachCopyButtons(container) { + container.querySelectorAll('pre').forEach(pre => { + if (pre.querySelector('.copy-btn')) return; // déjà un bouton + + const btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.textContent = 'Copy'; + btn.onclick = () => { + const code = pre.querySelector('code'); + const text = code ? code.textContent : pre.textContent; + navigator.clipboard.writeText(text).then(() => { + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = 'Copy', 2000); + }); + }; + pre.style.position = 'relative'; + pre.appendChild(btn); + }); +} + +// Lightbox functions +function openLightbox(imageUrl) { + const lightbox = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightbox-image'); + if (lightbox && lightboxImg) { + lightboxImg.src = imageUrl; + lightbox.classList.add('active'); + document.body.style.overflow = 'hidden'; + } +} + +function closeLightbox() { + const lightbox = document.getElementById('lightbox'); + if (lightbox) { + lightbox.classList.remove('active'); + document.body.style.overflow = ''; + } +} + +document.addEventListener('DOMContentLoaded', () => { + const lightbox = document.getElementById('lightbox'); + const closeBtn = document.querySelector('.lightbox-close'); + if (lightbox) { + lightbox.addEventListener('click', (e) => { + if (e.target === lightbox) closeLightbox(); + }); + if (closeBtn) { + closeBtn.addEventListener('click', closeLightbox); + } + } +}); + +function addImageMessage(dataURL, promptText) { + addMessage(`Generated image`, false); + const lastMsgDiv = chatContainer.lastElementChild; + const img = lastMsgDiv.querySelector('.generated-image'); + if (img) { + img.addEventListener('click', () => openLightbox(dataURL)); + } + const lastMsg = currentChat.messages[currentChat.messages.length - 1]; + lastMsg.isImage = true; + lastMsg.imageData = dataURL; + lastMsg.text = promptText; +} + +// ============================================ +// Génération d'Image via /genimg +// ============================================ +async function handleImageGeneration(description) { + addMessage(`/genimg ${description}`, true); + + const generatingDiv = document.createElement('div'); + generatingDiv.classList.add('message', 'assistant-message'); + generatingDiv.innerHTML = ` +
Assistant
+
+

Génération d'image en cours... (peut prendre jusqu'à 60 secondes)

+
+ `; + chatContainer.appendChild(generatingDiv); + chatContainer.scrollTop = chatContainer.scrollHeight; + + userInput.disabled = true; + sendButton.disabled = true; + + try { + const response = await fetch(`${MCP_SERVER_URL}/generate-image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + positive_prompt: description, + negative_prompt: 'text, watermark, blurry, low quality' + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + generatingDiv.remove(); + addImageMessage(data.image_url, description); + + } catch (error) { + console.error('Image generation error:', error); + generatingDiv.remove(); + addMessage(`Erreur lors de la génération d'image: ${error.message}`, false); + } finally { + userInput.disabled = false; + sendButton.disabled = false; + userInput.focus(); + } +} + +// ============================================ +// Envoi de Messages (Chat & Commande /genimg) +// ============================================ +async function sendMessage() { + let message = userInput.value.trim(); + if (!message && !pendingImage) return; + + // Détection /genimg + const genimgMatch = message.match(GENIMG_PATTERN); + if (genimgMatch) { + const description = genimgMatch[1].trim(); + userInput.value = ''; + await handleImageGeneration(description); + return; + } + + if (pendingImage) { + let promptText = message || "What's in this image?"; + addImageMessage(pendingImage, promptText); + pendingImage = null; + imagePreview.style.display = "none"; + userInput.value = ""; + } else { + addMessage(message, true); + } + + const conversationHistory = buildConversationHistory(); + + // Message assistant temporaire pour streaming + const assistantMessageElement = document.createElement('div'); + assistantMessageElement.classList.add('message', 'assistant-message'); + + const headerDiv = document.createElement('div'); + headerDiv.classList.add('message-header'); + headerDiv.textContent = 'Assistant'; + assistantMessageElement.appendChild(headerDiv); + + if (currentModel) { + const modelDiv = document.createElement('div'); + modelDiv.classList.add('message-model'); + modelDiv.textContent = currentModel; + assistantMessageElement.appendChild(modelDiv); + } + + const assistantContentDiv = document.createElement('div'); + assistantContentDiv.classList.add('message-content'); + assistantContentDiv.innerHTML = '

Thinking...

'; + assistantMessageElement.appendChild(assistantContentDiv); + + chatContainer.appendChild(assistantMessageElement); + chatContainer.scrollTop = chatContainer.scrollHeight; + + userInput.value = ''; + userInput.disabled = true; + sendButton.disabled = true; + + const serverUrl = serverUrlInput.value.trim(); + const startTime = performance.now(); + let accumulatedText = ''; + + try { + const headers = { 'Content-Type': 'application/json' }; + const apiKey = apiKeyInput.value.trim(); + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + const response = await fetch(`${serverUrl}/v1/chat/completions`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: currentModel, + messages: conversationHistory, + temperature: 0.7, + max_tokens: 4096, + stream: true + }) + }); + if (!response.ok) throw new Error('Server response was not ok'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + if (value) { + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(line => line.trim() !== ''); + for (const line of lines) { + if (line.startsWith("data:")) { + const dataStr = line.slice(5).trim(); + if (dataStr === "[DONE]") { done = true; break; } + try { + const parsed = JSON.parse(dataStr); + const delta = parsed.choices[0].delta; + if (delta && delta.content) { + accumulatedText += delta.content; + assistantContentDiv.innerHTML = marked.parse(accumulatedText); + assistantMessageElement.querySelectorAll('pre code').forEach(block => { + hljs.highlightElement(block); + }); + attachCopyButtons(assistantMessageElement); + if (typeof MathJax !== 'undefined') { + MathJax.typesetPromise([assistantMessageElement]).catch(err => console.error(err)); + } + } + } catch (err) { + console.error("Error parsing stream chunk", err); + } + } else if (line.startsWith("event:")) { + const eventType = line.slice(6).trim(); + if (eventType === "error") { + console.error("Received error event from server:", line); + addMessage("Error: Received error event from server", false); + done = true; + break; + } + } + } + } + } + const endTime = performance.now(); + const timeElapsed = ((endTime - startTime) / 1000).toFixed(2); + if (currentChat) { + currentChat.messages.push({ content: accumulatedText, isUser: false, isImage: false }); + if (currentChat.name.startsWith('Conversation')) { + const snippet = accumulatedText.split(' ').slice(0, 7).join(' '); + currentChat.name = snippet ? `Conversation: ${snippet}...` : currentChat.name; + updateChatList(); + } + } + } catch (error) { + console.error('Error:', error); + addMessage('Error: Unable to get a response from the server. Please try again.', false); + isConnected = false; + updateConnectionStatus('Disconnected', false); + } finally { + userInput.disabled = false; + sendButton.disabled = false; + userInput.focus(); + } +} + +// ============================================ +// Upload d'Image +// ============================================ +uploadButton.addEventListener('click', () => { imageUpload.click(); }); + +imageUpload.addEventListener('change', () => { + const file = imageUpload.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + pendingImage = e.target.result; + imagePreview.innerHTML = ``; + imagePreview.style.display = "block"; + }; + reader.readAsDataURL(file); + imageUpload.value = ""; +}); + +// ============================================ +// Event Listeners +// ============================================ +connectButton.addEventListener('click', () => { + if (isConnected) { + isConnected = false; + updateConnectionStatus('Disconnected', false); + userInput.disabled = true; + sendButton.disabled = true; + addMessage('Disconnected from server.', false); + currentModel = ''; + modelSelect.disabled = true; + } else { + connectToServer(); + } +}); + +userInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); }); +sendButton.addEventListener('click', sendMessage); +newChatButton.addEventListener('click', () => { createNewChat(); }); +toggleSidebarButton.addEventListener('click', () => { chatSidebar.classList.toggle('collapsed'); }); + +// ============================================ +// Initialisation +// ============================================ +initSystemPrompt(); +setupServerSelection(); + +serverUrlInput.focus(); diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..7365981 --- /dev/null +++ b/start.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# LM Studio Web - Startup Script +# Lance le serveur web (frontend) et le serveur MCP (backend image generation) + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color +BLUE='\033[0;34m' + +echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ LM Studio Web Chat - Démarrage ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}" + +# 1. Check ComfyUI +echo -e "\n${YELLOW}[1/3] Vérification de ComfyUI...${NC}" +if lsof -Pi :8188 -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e " ${GREEN}✓ ComfyUI est en cours d'exécution sur le port 8188${NC}" +else + echo -e " ${RED}✗ ComfyUI n'est PAS en cours d'exécution${NC}" + echo -e " ${YELLOW}→ Veuillez démarrer ComfyUI d'abord :${NC}" + echo -e " ${YELLOW} cd /chemin/vers/ComfyUI && python main.py${NC}" + echo -e " ${YELLOW} (ComfyUI doit écouter sur http://127.0.0.1:8188)${NC}" + exit 1 +fi + +# 2. Start Web Server (frontend) +echo -e "\n${YELLOW}[2/3] Démarrage du serveur web (frontend)...${NC}" +cd "$(dirname "$0")" +if lsof -Pi :8084 -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e " ${YELLOW}⚠ Le port 8084 est déjà occupé${NC}" + echo -e " ${YELLOW}→ Le serveur web semble déjà en cours d'exécution${NC}" +else + python3 -m http.server 8084 > server.log 2>&1 & + SERVER_PID=$! + echo $SERVER_PID > .web_server.pid + sleep 1 + if ps -p $SERVER_PID > /dev/null; then + echo -e " ${GREEN}✓ Serveur web démarré sur http://localhost:8084${NC}" + echo -e " ${GREEN} PID: $SERVER_PID${NC}" + else + echo -e " ${RED}✗ Échec du démarrage du serveur web${NC}" + cat server.log + exit 1 + fi +fi + +# 3. Start MCP Server (backend) +echo -e "\n${YELLOW}[3/3] Démarrage du serveur MCP (backend image)...${NC}" +cd backend +if lsof -Pi :8085 -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e " ${YELLOW}⚠ Le port 8085 est déjà occupé${NC}" + echo -e " ${YELLOW}→ Le serveur MCP semble déjà en cours d'exécution${NC}" +else + uvicorn mcp_server:app --reload --port 8085 > mcp_server.log 2>&1 & + MCP_PID=$! + echo $MCP_PID > .mcp_server.pid + sleep 2 + if ps -p $MCP_PID > /dev/null; then + echo -e " ${GREEN}✓ Serveur MCP démarré sur http://localhost:8085${NC}" + echo -e " ${GREEN} PID: $MCP_PID${NC}" + else + echo -e " ${RED}✗ Échec du démarrage du serveur MCP${NC}" + cat mcp_server.log + exit 1 + fi +fi + +# Summary +echo -e "\n${GREEN}╔════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ Tous les services sont démarrés ! ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}" +echo "" +echo "🌐 Interface web: http://localhost:8084" +echo "🔧 MCP API: http://localhost:8085" +echo "" +echo "Pour arrêter les serveurs:" +echo " ${YELLOW}./stop.sh${NC} ou ${YELLOW}pkill -f 'start.sh'${NC}" +echo "" +echo "📝 Logs:" +echo " • Serveur web: $(pwd)/server.log" +echo " • Serveur MCP: $(pwd)/backend/mcp_server.log" +echo "" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..7733caa --- /dev/null +++ b/stop.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# LM Studio Web - Stop Script + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' +BLUE='\033[0;34m' + +echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ LM Studio Web Chat - Arrêt ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}" + +cd "$(dirname "$0")" + +# Stop Web Server +if [ -f .web_server.pid ]; then + SERVER_PID=$(cat .web_server.pid) + if ps -p $SERVER_PID > /dev/null; then + echo -e "${YELLOW}→ Arrêt du serveur web (PID $SERVER_PID)...${NC}" + kill $SERVER_PID + sleep 1 + rm -f .web_server.pid + echo -e " ${GREEN}✓ Serveur web arrêté${NC}" + else + echo -e " ${YELLOW}⚠ Serveur web (PID $SERVER_PID) déjà arrêté${NC}" + rm -f .web_server.pid + fi +else + # Try to find by port + SERVER_PID=$(lsof -ti:8084) + if [ -n "$SERVER_PID" ]; then + echo -e "${YELLOW}→ Arrêt du serveur web (PID $SERVER_PID)...${NC}" + kill $SERVER_PID + sleep 1 + echo -e " ${GREEN}✓ Serveur web arrêté${NC}" + else + echo -e " ${YELLOW}⚠ Aucun serveur web trouvé sur le port 8084${NC}" + fi +fi + +# Stop MCP Server +cd backend 2>/dev/null || true +if [ -f .mcp_server.pid ]; then + MCP_PID=$(cat .mcp_server.pid) + if ps -p $MCP_PID > /dev/null; then + echo -e "${YELLOW}→ Arrêt du serveur MCP (PID $MCP_PID)...${NC}" + kill $MCP_PID + sleep 1 + rm -f .mcp_server.pid + echo -e " ${GREEN}✓ Serveur MCP arrêté${NC}" + else + echo -e " ${YELLOW}⚠ Serveur MCP (PID $MCP_PID) déjà arrêté${NC}" + rm -f .mcp_server.pid + fi +else + # Try to find by port + MCP_PID=$(lsof -ti:8085) + if [ -n "$MCP_PID" ]; then + echo -e "${YELLOW}→ Arrêt du serveur MCP (PID $MCP_PID)...${NC}" + kill $MCP_PID + sleep 1 + echo -e " ${GREEN}✓ Serveur MCP arrêté${NC}" + else + echo -e " ${YELLOW}⚠ Aucun serveur MCP trouvé sur le port 8085${NC}" + fi +fi + +echo "" +echo -e "${GREEN}Tous les services ont été arrêtés.${NC}" +echo "" diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..52888f8 --- /dev/null +++ b/styles.css @@ -0,0 +1,469 @@ +:root { + /* Purple themed color scheme */ + --background-color: #1e0a3c; + --text-color: #ffffff; + --input-background: #2c0f4b; + --user-message-color: #6c2d8c; + --assistant-message-color: #442a6f; + --button-color: #8e44ad; + --accent-color: #bb86fc; + --border-radius: 8px; + --transition-speed: 0.3s; + --shadow: 0 2px 8px rgba(0,0,0,0.3); +} +* { box-sizing: border-box; } +body, html { + font-family: 'Inter', sans-serif; + margin: 0; + padding: 0; + height: 100%; + background-color: var(--background-color); + color: var(--text-color); +} +#app { display: flex; flex-direction: column; height: 100%; } +/* Header: Server URL & Model Selection */ +#server-url-container { + padding: 0.75rem; + background-color: var(--input-background); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + box-shadow: var(--shadow); +} +#server-url { + flex-grow: 1; + padding: 0.5rem 0.75rem; + margin-right: 0.5rem; + margin-bottom: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; +} + /* Server type selector */ + #server-type-select { + padding: 0.5rem 0.75rem; + margin-right: 0.5rem; + margin-bottom: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--input-background); + color: var(--text-color); + font-size: 0.95rem; + cursor: pointer; + min-width: 120px; + } + #custom-url { + flex-grow: 1; + padding: 0.5rem 0.75rem; + margin-right: 0.5rem; + margin-bottom: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; + display: none; + } + #api-key { + padding: 0.5rem 0.75rem; + margin-right: 0.5rem; + margin-bottom: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; + display: none; + min-width: 150px; + } + +/* Model selection dropdown */ +#model-select { + padding: 0.5rem; + font-size: 0.95rem; + margin-right: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); +} +#connect-button { + padding: 0.55rem 1rem; + background-color: var(--button-color); + color: var(--text-color); + border: none; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 0.95rem; + transition: background-color var(--transition-speed); +} +#connect-button:hover { background-color: var(--accent-color); } +#connection-status { + width: 100%; + text-align: center; + padding: 0.5rem; + font-size: 0.85rem; + background-color: var(--input-background); + color: #ddd; +} +/* Main content: sidebar + chat area */ +#main-content { + display: flex; + flex-grow: 1; + overflow: hidden; +} +/* Chat sidebar */ +#chat-sidebar { + width: 250px; + background-color: var(--input-background); + border-right: 1px solid #333; + overflow: hidden; + transition: width var(--transition-speed) ease; + position: relative; + flex-shrink: 0; +} +#chat-sidebar.collapsed { width: 40px; } +/* Toggle sidebar button (right offset) */ +#toggle-sidebar { + position: absolute; + top: 10px; + right: 10px; + background-color: var(--button-color); + border: none; + border-radius: 50%; + width: 30px; + height: 30px; + color: var(--text-color); + cursor: pointer; + transition: background-color var(--transition-speed); + z-index: 10; +} +#toggle-sidebar:hover { background-color: var(--accent-color); } +/* Sidebar content */ +.sidebar-content { + padding: 1rem; + transition: opacity var(--transition-speed) ease; +} +#chat-sidebar.collapsed .sidebar-content { + opacity: 0; + pointer-events: none; +} +#chat-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + font-size: 1.1rem; + font-weight: 600; +} +#system-prompt-container { + margin-bottom: 1rem; +} +#system-prompt-label { + display: block; + font-size: 0.85rem; + margin-bottom: 0.25rem; + color: #aaa; +} +#system-prompt-input { + width: 100%; + padding: 0.5rem; + font-size: 0.85rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); + resize: vertical; + min-height: 60px; + max-height: 120px; + font-family: inherit; +} +#system-prompt-input:focus { + outline: 2px solid var(--accent-color); +} +#reset-system-prompt { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + margin-top: 0.25rem; + background-color: transparent; + color: var(--accent-color); + border: 1px solid var(--accent-color); + border-radius: var(--border-radius); + cursor: pointer; +} +#reset-system-prompt:hover { + background-color: var(--accent-color); + color: var(--text-color); +} +#new-chat-button { + width: 100%; + padding: 0.5rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--button-color); + color: var(--text-color); + cursor: pointer; + margin-bottom: 1rem; + transition: background-color var(--transition-speed); + font-size: 0.9rem; +} +#new-chat-button:hover { background-color: var(--accent-color); } +#chat-list { list-style: none; padding: 0; margin: 0; } +/* Chat titles */ +#chat-list li { + padding: 0.75rem; + border-radius: var(--border-radius); + margin-bottom: 0.5rem; + background-color: var(--background-color); + cursor: pointer; + transition: background-color var(--transition-speed); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9rem; + position: relative; +} +#chat-list li:hover { background-color: var(--input-background); } +#chat-list li.active { background-color: var(--button-color); font-weight: 600; } +/* Custom context menu for deleting chats */ +#context-menu { + display: none; + position: absolute; + background-color: var(--button-color); + color: var(--text-color); + padding: 0.5rem; + border-radius: 4px; + z-index: 1000; + cursor: pointer; + font-size: 0.9rem; +} +/* Chat section */ +#chat-section { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: linear-gradient(135deg, #2c0f4b, #1e0a3c); + padding: 1rem; + border-radius: 10px; + margin: 1rem; +} +#chat-container { + flex-grow: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + background: rgba(255,255,255,0.05); + border-radius: 8px; +} +/* Message card styling */ +.message { + max-width: 85%; + padding: 0.75rem 1rem; + border-radius: var(--border-radius); + word-wrap: break-word; + font-size: 0.95rem; + line-height: 1.5; + background-color: var(--assistant-message-color); + box-shadow: var(--shadow); + animation: fadeIn 0.3s ease; +} +.user-message { align-self: flex-end; background-color: var(--user-message-color); } +.assistant-message { align-self: flex-start; background-color: var(--assistant-message-color); } +.message-header { + font-weight: 600; + margin-bottom: 0.35rem; + font-size: 0.85rem; +} +.message-model { + font-size: 0.75rem; + color: #ccc; + margin-bottom: 0.35rem; +} +.message-content { margin-bottom: 0.5rem; } +.message-metrics { font-size: 0.75rem; color: #ccc; text-align: right; } +/* Enhanced Markdown styling */ +.message-content h1, +.message-content h2, +.message-content h3, +.message-content h4, +.message-content h5, +.message-content h6 { + margin: 0.5rem 0; + font-weight: 600; +} +.message-content p { margin: 0.5rem 0; line-height: 1.6; } +.message-content code { + background-color: rgba(27,31,35,0.15); + padding: 0.2em 0.4em; + border-radius: 4px; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; +} +.message-content pre { + background-color: #282c34; + color: #abb2bf; + padding: 0.8rem; + overflow-x: auto; + border-radius: 6px; + margin: 0.5rem 0; +} +.message-content blockquote { + border-left: 4px solid var(--button-color); + margin: 1rem 0; + padding: 0.5rem 1rem; + color: #ccc; + background: rgba(142,68,173,0.1); +} +/* Input container */ +#input-container { + padding: 0.75rem; + background-color: rgba(44,15,75,0.8); + border-top: 1px solid #333; + display: flex; + align-items: center; + gap: 0.5rem; + box-shadow: var(--shadow); + border-radius: 10px; +} +/* Image upload button */ +#upload-button { + background-color: var(--button-color); + color: var(--text-color); + border: none; + border-radius: 50%; + width: 2.75rem; + height: 2.75rem; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + transition: background-color var(--transition-speed); +} +#upload-button:hover { background-color: var(--accent-color); } +/* Image preview */ +#image-preview { display: none; max-width: 50px; } +#user-input { + flex-grow: 1; + padding: 0.6rem 0.75rem; + border: none; + border-radius: var(--border-radius); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; +} +#user-input::placeholder { color: #bbb; } +#send-button { + background-color: var(--button-color); + color: var(--text-color); + border: none; + border-radius: 50%; + width: 2.75rem; + height: 2.75rem; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + transition: background-color var(--transition-speed); +} +#send-button:hover { background-color: var(--accent-color); } +@media (max-width: 480px) { + .message { max-width: 90%; } + #server-url-container { flex-direction: column; align-items: stretch; } + #server-url, #connect-button, #model-select { width: 100%; margin-right: 0; margin-bottom: 0.5rem; } + #chat-sidebar { display: none; } +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +/* Copy code button styles */ +.copy-btn { + position: absolute; + top: 5px; + right: 5px; + background-color: var(--accent-color); + color: var(--text-color); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; + padding: 2px 6px; + opacity: 0.8; + transition: opacity 0.2s; +} +.copy-btn:hover { opacity: 1; } + +/* --- Lightbox pour images générées --- */ +.generated-image { + max-width: 300px; + max-height: 300px; + cursor: zoom-in; + border: 2px solid var(--accent-color); + border-radius: var(--border-radius); + transition: transform 0.2s ease, box-shadow 0.2s ease; + display: block; + margin: 0.5rem 0; +} + +.generated-image:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(187, 134, 252, 0.3); +} + +.lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + display: none; + justify-content: center; + align-items: center; + z-index: 10000; + cursor: zoom-out; + animation: fadeIn 0.2s ease; +} + +.lightbox.active { + display: flex; +} + +.lightbox img { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + animation: zoomIn 0.3s ease; +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 30px; + font-size: 2rem; + color: var(--accent-color); + cursor: pointer; + transition: color 0.2s; + z-index: 10001; +} + +.lightbox-close:hover { + color: #fff; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes zoomIn { + from { transform: scale(0.8); opacity: 0; } + to { transform: scale(1); opacity: 1; } +}