Newer
Older
lm-studio-web-chat / main.js
// ============================================
// 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(`<img src="${dataURL}" class="generated-image" alt="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 = `
    <div class="message-header">Assistant</div>
    <div class="message-content">
      <p><i class="fas fa-spinner fa-spin"></i> Génération d'image en cours... (peut prendre jusqu'à 60 secondes)</p>
    </div>
  `;
  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 = '<p><i class="fas fa-spinner fa-spin"></i> Thinking...</p>';
  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 = `<img src="${pendingImage}" style="max-width:100%; border-radius: var(--border-radius);" />`;
    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();