const BACKENDS = { plato: { prod: '/api/plato', dev: 'http://192.168.1.74:8080/v1', name: 'Plato (192.168.1.74)' }, stoic: { prod: '/api/stoic', dev: 'http://192.168.1.159:1234/v1', name: 'Stoic (192.168.1.159)' }, ollama: { prod: '/api/ollama', dev: 'http://192.168.1.159:8081/v1', name: 'Ollama (192.168.1.159:8081)' } } const IS_PRODUCTION = window.location.hostname === 'jarvis-lan.appmodel.nl' const API_KEY = 'not-needed' const MAX_CHAT_HISTORY = 50 let currentBackendModel = null let availableBackendModels = [] let currentStreamController = null let isStreaming = false let chatHistory = JSON.parse(localStorage.getItem('chatHistory')) || [] // ✅ FIX: Generate unique IDs and validate message structure chatHistory = chatHistory.map((message, index) => ({ role: message.role || 'user', content: message.content || '', markdown: message.markdown || false, messageId: message.messageId || `msg-${Date.now()}-${index}` })).filter(msg => msg.content && typeof msg.content === 'string') const welcomeMessage = `# Welcome to LM Studio Chat! I now support **full conversation history**, **real-time streaming responses** and **dark, readable text formatting**. ## Features: 1. **Full Chat History** - Complete conversation context is now sent to the AI 2. **Smart History Management** - Automatically keeps the last ${MAX_CHAT_HISTORY} messages 3. **Streaming Mode** (enabled by default) - Watch responses appear word-by-word 4. **Markdown Rendering** - Proper formatting for code, lists, tables, and more 5. **Readable Dark Text** - No more eye strain from light gray text ## Try it out: - Ask follow-up questions that reference earlier messages - Have a multi-turn conversation with full context - Ask "what did I just ask?" to test history retention - Watch the streaming response in real-time! > *Tip: You can toggle streaming and markdown using the checkboxes below.*` // ✅ NEW: Robust parser for backend:model format function splitBackendModel(value) { if (!value || value === ':') { return { backendKey: '', model: '' }; } const colonIndex = value.indexOf(':'); if (colonIndex === -1) { return { backendKey: value, model: '' }; } return { backendKey: value.substring(0, colonIndex), model: value.substring(colonIndex + 1) }; } function getApiUrl() { const { backendKey, model } = splitBackendModel(currentBackendModel); const effectiveBackendKey = backendKey || 'plato'; const backend = BACKENDS[effectiveBackendKey]; if (!backend) { throw new Error(`Invalid backend: '${effectiveBackendKey}'`); } return IS_PRODUCTION ? backend.prod : backend.dev; } function updateBackendModelDisplay() { const { backendKey, model } = splitBackendModel(currentBackendModel); const backend = BACKENDS[backendKey]; if (!backend) return; const displayText = IS_PRODUCTION ? `${backend.prod} - ${backend.name}` : backend.dev; const displayElement = document.getElementById('backendDisplay'); if (displayElement) { displayElement.textContent = `${displayText} - Model: ${model || 'None'}`; } } async function fetchBackendModels() { const selector = document.getElementById('backendModelSelector'); let allModels = []; for (const [backendKey, backendConfig] of Object.entries(BACKENDS)) { try { const apiUrl = IS_PRODUCTION ? backendConfig.prod : backendConfig.dev; console.log(`Fetching models from ${backendKey}: ${apiUrl}`); const response = await fetch(`${apiUrl}/models`, { signal: AbortSignal.timeout(5000) // 5 second timeout }); if (response.ok) { const data = await response.json(); if (data.data && Array.isArray(data.data)) { const models = data.data.map(model => ({ backend: backendKey, model: model.id })); allModels = [...allModels, ...models]; } } else { console.error(`Failed to fetch models from ${backendKey}: ${response.status} ${response.statusText}`); } } catch (error) { console.error(`Error fetching models from ${backendKey}:`, error); } } availableBackendModels = allModels; if (selector) { if (availableBackendModels.length === 0) { selector.innerHTML = ''; } else { populateBackendModelSelector(); // Auto-select if no valid model is set const hasModel = currentBackendModel && currentBackendModel.includes(':') && currentBackendModel.split(':')[1]; if (!hasModel && availableBackendModels.length > 0) { currentBackendModel = `${availableBackendModels[0].backend}:${availableBackendModels[0].model}`; selector.value = currentBackendModel; } } } updateBackendModelDisplay(); } function populateBackendModelSelector() { const selector = document.getElementById('backendModelSelector'); if (!selector) return; if (availableBackendModels.length === 0) { selector.innerHTML = ''; return; } selector.innerHTML = availableBackendModels.map(backendModel => `` ).join(''); if (currentBackendModel && selector.querySelector(`option[value="${currentBackendModel}"]`)) { selector.value = currentBackendModel; } else if (availableBackendModels.length > 0) { // If current selection is invalid, reset to first available currentBackendModel = `${availableBackendModels[0].backend}:${availableBackendModels[0].model}`; selector.value = currentBackendModel; } } function getApiMessages() { return chatHistory.slice(-MAX_CHAT_HISTORY).map(({ role, content }) => ({ role, content })); } function trimChatHistory() { if (chatHistory.length > MAX_CHAT_HISTORY) { chatHistory = chatHistory.slice(-MAX_CHAT_HISTORY); localStorage.setItem('chatHistory', JSON.stringify(chatHistory)); } } document.addEventListener('DOMContentLoaded', () => { const backendModelSelector = document.getElementById('backendModelSelector'); if (backendModelSelector) { backendModelSelector.addEventListener('change', (e) => { currentBackendModel = e.target.value; updateBackendModelDisplay(); fetchBackendModels(); }); } }); const chatLog = document.getElementById('chatLog'); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const streamToggle = document.getElementById('streamToggle'); const markdownToggle = document.getElementById('markdownToggle'); const modelNameSpan = document.getElementById('modelName'); marked.setOptions({ gfm: true, breaks: true, highlight: function(code, lang) { if (window.hljs) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; } return code; } }); function autoResize(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; } function addMessage(role, content, markdown = false, messageId = null, saveToHistory = true) { const messageDiv = document.createElement('div'); messageDiv.className = `message message-${role}`; if (messageId) { messageDiv.id = messageId; } const headerDiv = document.createElement('div'); headerDiv.className = `message-header ${role}`; const avatar = document.createElement('div'); avatar.className = `avatar ${role}-avatar`; avatar.textContent = role === 'user' ? '👤' : '🤖'; const name = document.createElement('span'); name.textContent = role === 'user' ? 'You' : 'Assistant'; headerDiv.appendChild(avatar); headerDiv.appendChild(name); const contentDiv = document.createElement('div'); if (markdown && role === 'assistant' && markdownToggle.checked) { contentDiv.className = 'markdown-content'; const processedContent = parseThinkingTags(content); contentDiv.innerHTML = marked.parse(processedContent); if (window.hljs) { setTimeout(() => { contentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); }, 0); } } else { const processedContent = parseThinkingTags(content); contentDiv.innerHTML = processedContent; contentDiv.style.whiteSpace = 'pre-wrap'; contentDiv.style.padding = '8px 0'; contentDiv.style.color = '#e0e0e0'; } messageDiv.appendChild(headerDiv); messageDiv.appendChild(contentDiv); if (chatLog) { chatLog.appendChild(messageDiv); chatLog.scrollTop = chatLog.scrollHeight; } if (saveToHistory) { chatHistory.push({ role, content, markdown, messageId }); trimChatHistory(); localStorage.setItem('chatHistory', JSON.stringify(chatHistory)); } return contentDiv; } function parseThinkingTags(content) { let result = content; let thinkingCounter = 0; const completeThinkRegex = /\[THINK\]([\s\S]*?)\[\/THINK\]/gi; result = result.replace(completeThinkRegex, (match, thinkContent) => { thinkingCounter++; const id = `thinking-${Date.now()}-${thinkingCounter}`; return `
? ? Thinking...
${thinkContent.trim()}
`; }); const incompleteThinkRegex = /\[THINK\]([\s\S]*?)$/gi; result = result.replace(incompleteThinkRegex, (match, thinkContent) => { thinkingCounter++; const id = `thinking-${Date.now()}-${thinkingCounter}`; return `
? ? Thinking...
${thinkContent.trim()}
`; }); return result; } window.toggleThinking = function(id) { const content = document.getElementById(id); const toggle = document.getElementById(id + '-toggle'); if (content && toggle) { content.classList.toggle('hidden'); toggle.classList.toggle('collapsed'); } }; function updateMessageContent(contentDiv, newContent, markdown = false, role) { const processedContent = parseThinkingTags(newContent); if (markdown && role === 'assistant' && markdownToggle && markdownToggle.checked) { contentDiv.innerHTML = marked.parse(processedContent); if (window.hljs) { setTimeout(() => { contentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); }, 0); } } else { contentDiv.innerHTML = processedContent; contentDiv.style.whiteSpace = 'pre-wrap'; contentDiv.style.padding = '8px 0'; contentDiv.style.color = '#e0e0e0'; } if (chatLog) { chatLog.scrollTop = chatLog.scrollHeight; } } function showTypingIndicator() { const typingDiv = document.createElement('div'); typingDiv.id = 'typingIndicator'; typingDiv.className = 'message message-assistant'; const headerDiv = document.createElement('div'); headerDiv.className = 'message-header assistant'; const avatar = document.createElement('div'); avatar.className = 'avatar assistant-avatar'; avatar.textContent = '🤖'; const name = document.createElement('span'); name.textContent = 'Assistant'; headerDiv.appendChild(avatar); headerDiv.appendChild(name); const dotsDiv = document.createElement('div'); dotsDiv.className = 'typing-indicator'; dotsDiv.innerHTML = `
Thinking... `; typingDiv.appendChild(headerDiv); typingDiv.appendChild(dotsDiv); if (chatLog) { chatLog.appendChild(typingDiv); chatLog.scrollTop = chatLog.scrollHeight; } return typingDiv; } function removeTypingIndicator(typingDiv) { if (typingDiv && typingDiv.parentNode) { typingDiv.parentNode.removeChild(typingDiv); } } async function handleStreamingResponse(userMessage) { const messageId = 'msg-' + Date.now(); let accumulatedContent = ''; let contentDiv = null; const messageDiv = document.createElement('div'); messageDiv.className = 'message message-assistant'; messageDiv.id = messageId; const headerDiv = document.createElement('div'); headerDiv.className = 'message-header assistant'; const avatar = document.createElement('div'); avatar.className = 'avatar assistant-avatar'; avatar.textContent = '🤖'; const name = document.createElement('span'); name.textContent = 'Assistant'; headerDiv.appendChild(avatar); headerDiv.appendChild(name); contentDiv = document.createElement('div'); contentDiv.className = 'markdown-content'; messageDiv.appendChild(headerDiv); messageDiv.appendChild(contentDiv); if (chatLog) { chatLog.appendChild(messageDiv); } const cursorSpan = document.createElement('span'); cursorSpan.className = 'streaming-cursor'; contentDiv.appendChild(cursorSpan); // ✅ FIX: Create AbortController BEFORE fetch currentStreamController = new AbortController(); isStreaming = true; try { const { backendKey, model } = splitBackendModel(currentBackendModel); const selectedModel = model || availableBackendModels[0]?.model || 'local-model'; const requestBody = { model: selectedModel, messages: getApiMessages(), stream: true, temperature: 0.7, max_tokens: 2000 }; const response = await fetch(`${getApiUrl()}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify(requestBody), signal: currentStreamController.signal // ✅ Now works correctly }); if (!response.ok) { throw new Error(`API Error: ${response.status} ${response.statusText}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let updateScheduled = false; let lastUpdate = Date.now(); const MIN_UPDATE_INTERVAL = 100; function scheduleUpdate() { if (updateScheduled) return; const now = Date.now(); const timeSinceLastUpdate = now - lastUpdate; if (timeSinceLastUpdate >= MIN_UPDATE_INTERVAL) { updateScheduled = true; requestAnimationFrame(() => { updateMessageContent(contentDiv, accumulatedContent, markdownToggle && markdownToggle.checked, 'assistant'); if (cursorSpan.parentNode) { contentDiv.appendChild(cursorSpan); } if (chatLog) { chatLog.scrollTop = chatLog.scrollHeight; } lastUpdate = Date.now(); updateScheduled = false; }); } else { updateScheduled = true; setTimeout(() => { requestAnimationFrame(() => { updateMessageContent(contentDiv, accumulatedContent, markdownToggle && markdownToggle.checked, 'assistant'); if (cursorSpan.parentNode) { contentDiv.appendChild(cursorSpan); } if (chatLog) { chatLog.scrollTop = chatLog.scrollHeight; } lastUpdate = Date.now(); updateScheduled = false; }); }, MIN_UPDATE_INTERVAL - timeSinceLastUpdate); } } while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n').filter(line => line.trim() !== ''); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') { isStreaming = false; currentStreamController = null; updateMessageContent(contentDiv, accumulatedContent, markdownToggle.checked, 'assistant'); if (cursorSpan.parentNode) { cursorSpan.parentNode.removeChild(cursorSpan); } chatLog.scrollTop = chatLog.scrollHeight; return; } try { const parsed = JSON.parse(data); if (parsed.choices && parsed.choices[0].delta.content) { accumulatedContent += parsed.choices[0].delta.content; scheduleUpdate(); } } catch (e) { console.log('Error parsing stream data:', e); } } } } updateMessageContent(contentDiv, accumulatedContent, markdownToggle.checked, 'assistant'); chatLog.scrollTop = chatLog.scrollHeight; } catch (error) { console.error('Streaming error:', error); if (error.name !== 'AbortError') { updateMessageContent(contentDiv, `Error: ${error.message}`, false, 'assistant'); } } finally { isStreaming = false; currentStreamController = null; sendBtn.disabled = false; if (cursorSpan && cursorSpan.parentNode) { cursorSpan.parentNode.removeChild(cursorSpan); } } } async function handleNonStreamingResponse(userMessage) { let typingIndicator = null; try { typingIndicator = showTypingIndicator(); const { backendKey, model } = splitBackendModel(currentBackendModel); const selectedModel = model || availableBackendModels[0]?.model || 'local-model'; const requestBody = { model: selectedModel, messages: getApiMessages(), stream: false, temperature: 0.7, max_tokens: 2000 }; const response = await fetch(`${getApiUrl()}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`API Error: ${response.status} ${response.statusText}`); } const data = await response.json(); removeTypingIndicator(typingIndicator); if (data.choices && data.choices.length > 0) { const assistantReply = data.choices[0].message.content; addMessage('assistant', assistantReply, markdownToggle.checked); } else { addMessage('assistant', 'No response generated.', false); } } catch (error) { console.error('Error:', error); if (typingIndicator) { removeTypingIndicator(typingIndicator); } addMessage('assistant', `Error: ${error.message}`, false); } finally { sendBtn.disabled = false; userInput.focus(); } } function stopStream() { if (currentStreamController) { currentStreamController.abort(); isStreaming = false; currentStreamController = null; sendBtn.disabled = false; sendBtn.textContent = 'Send'; } } async function sendMessage() { const userMessage = userInput.value.trim(); if (!userMessage || isStreaming) return; // ✅ FIX: Validate backend selection if (!currentBackendModel) { addMessage('assistant', 'Please select a backend model first.', false); return; } addMessage('user', userMessage, false); userInput.value = ''; userInput.style.height = 'auto'; sendBtn.disabled = true; if (streamToggle.checked) { sendBtn.textContent = 'Stop'; await handleStreamingResponse(userMessage); } else { await handleNonStreamingResponse(userMessage); } sendBtn.textContent = 'Send'; } sendBtn.addEventListener('click', () => { if (isStreaming) { stopStream(); } else { sendMessage(); } }); userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isStreaming) { sendMessage(); } } }); // ✅ Updated window.onload with better initialization window.onload = () => { if (userInput) userInput.focus(); // Set default backend BEFORE fetching if (!currentBackendModel) { currentBackendModel = 'plato'; } fetchBackendModels().then(() => { // Load chat history after models are fetched if (chatLog) { if (chatHistory.length === 0) { //addMessage('assistant', welcomeMessage, true, null, false); } else { chatHistory.forEach(msg => { if (typeof msg.content === 'string' && msg.content.trim()) { addMessage(msg.role, msg.content, msg.markdown, msg.messageId, false); } }); } } }); document.getElementById('resetBtn')?.addEventListener('click', resetChat); }; function resetChat() { chatHistory = []; localStorage.setItem('chatHistory', JSON.stringify(chatHistory)); if (chatLog) { chatLog.innerHTML = ''; addMessage('assistant', welcomeMessage, true, null, false); } }