// Configuration const BACKENDS = { plato : { prod: '/api/plato', dev : 'http://192.168.1.74:1234/v1', // Removed trailing space name: 'Plato (192.168.1.74)' }, stoic : { prod: '/api/stoic', dev : 'http://192.168.1.159:1234/v1', // Removed trailing space name: 'Stoic (192.168.1.159)' }, ollama: { prod: '/api/ollama', dev : 'http://192.168.1.159:8081/v1', // Removed trailing space 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 // Maximum messages to keep in history // Global state let currentBackend = 'plato' let currentModel = null let availableModels = [] let currentStreamController = null let isStreaming = false let chatHistory = JSON.parse(localStorage.getItem('chatHistory')) || [] // Stores conversation history // Ensure chatHistory is an array of objects with the correct structure chatHistory = chatHistory.map(message => ({ role : message.role || 'user', content : message.content || '', markdown : message.markdown || false, messageId: message.messageId || `msg-${ Date.now() }` })) // Get current API URL based on selected backend function getApiUrl() { const backend = BACKENDS[currentBackend] return IS_PRODUCTION ? backend.prod : backend.dev } // Update backend display function updateBackendDisplay() { const backend = BACKENDS[currentBackend] const displayText = IS_PRODUCTION ? `${ backend.prod } ? ${ backend.name }` : backend.dev document.getElementById('backendDisplay').textContent = displayText } // Fetch available models from backend async function fetchModels() { try { const response = await fetch(`${ getApiUrl() }/models`) if (response.ok) { const data = await response.json() availableModels = data.data || [] populateModelSelector() // Auto-select first model if none selected if (!currentModel && availableModels.length > 0) { currentModel = availableModels[0].id document.getElementById('modelSelector').value = currentModel } } else { console.error('Failed to fetch models:', response.statusText) document.getElementById('modelSelector').innerHTML = '' } } catch (error) { console.error('Error fetching models:', error) document.getElementById('modelSelector').innerHTML = '' } } // Populate model selector dropdown function populateModelSelector() { const selector = document.getElementById('modelSelector') if (availableModels.length === 0) { selector.innerHTML = '' return } selector.innerHTML = availableModels.map(model => `` ).join('') if (currentModel) { selector.value = currentModel } } // Get formatted messages for API (excludes UI-specific properties) function getApiMessages() { // Get last N messages and format for API return chatHistory.slice(-MAX_CHAT_HISTORY).map(({ role, content }) => ({ role, content })) } // Trim chat history if it exceeds maximum function trimChatHistory() { if (chatHistory.length > MAX_CHAT_HISTORY) { chatHistory = chatHistory.slice(-MAX_CHAT_HISTORY) console.log(`Chat history trimmed to ${ MAX_CHAT_HISTORY } messages`) // Save chat history to localStorage localStorage.setItem('chatHistory', JSON.stringify(chatHistory)) } } // Handle backend and model selection changes document.addEventListener('DOMContentLoaded', () => { const backendSelector = document.getElementById('backendSelector') const modelSelector = document.getElementById('modelSelector') backendSelector.value = currentBackend updateBackendDisplay() fetchModels() backendSelector.addEventListener('change', (e) => { currentBackend = e.target.value updateBackendDisplay() console.log('Backend switched to:', currentBackend, '?', getApiUrl()) fetchModels() // Reload models for new backend }) modelSelector.addEventListener('change', (e) => { currentModel = e.target.value console.log('Model selected:', currentModel) }) }) // DOM Elements 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') // Initialize marked with options 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 } }) // Auto-resize textarea function autoResize(textarea) { textarea.style.height = 'auto' textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px' } // Add message to chat function addMessage(role, content, markdown = false, messageId = null) { 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' // Parse thinking tags before markdown const processedContent = parseThinkingTags(content) contentDiv.innerHTML = marked.parse(processedContent) // Apply syntax highlighting if hljs is available if (window.hljs) { setTimeout(() => { contentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block) }) }, 0) } } else { // Parse thinking tags for non-markdown too const processedContent = parseThinkingTags(content) contentDiv.innerHTML = processedContent contentDiv.style.whiteSpace = 'pre-wrap' contentDiv.style.padding = '8px 0' contentDiv.style.color = '#2d3339' } messageDiv.appendChild(headerDiv) messageDiv.appendChild(contentDiv) chatLog.appendChild(messageDiv) chatLog.scrollTop = chatLog.scrollHeight // Store message in history (excluding UI-specific properties) chatHistory.push({ role, content, markdown, messageId }) // Trim history if it gets too long trimChatHistory() // Save chat history to localStorage localStorage.setItem('chatHistory', JSON.stringify(chatHistory)) return contentDiv } // Parse [THINK] tags and create collapsible sections function parseThinkingTags(content) { let result = content let thinkingCounter = 0 // Handle complete [THINK]...[/THINK] blocks const completeThinkRegex = /\[THINK\]([\s\S]*?)\[\/THINK\]/gi result = result.replace(completeThinkRegex, (match, thinkContent) => { thinkingCounter++ const id = `thinking-${ Date.now() }-${ thinkingCounter }` return `
? ? Thinking...
${ thinkContent.trim() }
` }) // Handle incomplete [THINK] blocks (still streaming) 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 } // Toggle thinking section visibility 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') } } // Update message content (for streaming) function updateMessageContent(contentDiv, newContent, markdown = false, role) { // Parse thinking tags first const processedContent = parseThinkingTags(newContent) if (markdown && role === 'assistant' && markdownToggle.checked) { contentDiv.innerHTML = marked.parse(processedContent) // Apply syntax highlighting if hljs is available if (window.hljs) { setTimeout(() => { contentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block) }) }, 0) } } else { contentDiv.textContent = newContent } } // Show typing indicator 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) chatLog.appendChild(typingDiv) chatLog.scrollTop = chatLog.scrollHeight return typingDiv } // Remove typing indicator function removeTypingIndicator(typingDiv) { if (typingDiv && typingDiv.parentNode) { typingDiv.parentNode.removeChild(typingDiv) } } // Handle streaming response async function handleStreamingResponse(userMessage) { const messageId = 'msg-' + Date.now() let accumulatedContent = '' let contentDiv = null // Create initial empty message 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) chatLog.appendChild(messageDiv) // Add streaming cursor const cursorSpan = document.createElement('span') cursorSpan.className = 'streaming-cursor' contentDiv.appendChild(cursorSpan) try { // Auto-select first model if none selected if (!currentModel && availableModels.length > 0) { currentModel = availableModels[0].id document.getElementById('modelSelector').value = currentModel } const requestBody = { model : currentModel || 'local-model', messages : getApiMessages(), // Send full chat history 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) }) if (!response.ok) { throw new Error(`API Error: ${ response.status } ${ response.statusText }`) } const reader = response.body.getReader() const decoder = new TextDecoder() isStreaming = true currentStreamController = new AbortController() // Debounce DOM updates to reduce stutter let updateScheduled = false let lastUpdate = Date.now() const MIN_UPDATE_INTERVAL = 100 // ms, update at most every 100ms function scheduleUpdate() { if (updateScheduled) return const now = Date.now() const timeSinceLastUpdate = now - lastUpdate if (timeSinceLastUpdate >= MIN_UPDATE_INTERVAL) { // Update immediately if enough time has passed updateScheduled = true requestAnimationFrame(() => { updateMessageContent(contentDiv, accumulatedContent, markdownToggle.checked, 'assistant') if (cursorSpan.parentNode) { contentDiv.appendChild(cursorSpan) } chatLog.scrollTop = chatLog.scrollHeight lastUpdate = Date.now() updateScheduled = false }) } else { // Schedule update after remaining time updateScheduled = true setTimeout(() => { requestAnimationFrame(() => { updateMessageContent(contentDiv, accumulatedContent, markdownToggle.checked, 'assistant') if (cursorSpan.parentNode) { contentDiv.appendChild(cursorSpan) } 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 // Final update and remove cursor 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) } } } } // Final update 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 // Remove cursor if still exists if (cursorSpan.parentNode) { cursorSpan.parentNode.removeChild(cursorSpan) } } } // Handle non-streaming response async function handleNonStreamingResponse(userMessage) { const typingIndicator = showTypingIndicator() try { // Auto-select first model if none selected if (!currentModel && availableModels.length > 0) { currentModel = availableModels[0].id document.getElementById('modelSelector').value = currentModel } const requestBody = { model : currentModel || 'local-model', messages : getApiMessages(), // Send full chat history 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) removeTypingIndicator(typingIndicator) addMessage('assistant', `Error: ${ error.message }`, false) } finally { sendBtn.disabled = false userInput.focus() } } // Stop current stream function stopStream() { if (currentStreamController) { currentStreamController.abort() isStreaming = false currentStreamController = null sendBtn.disabled = false sendBtn.textContent = 'Send' } } // Send message async function sendMessage() { const userMessage = userInput.value.trim() if (!userMessage || isStreaming) return // Add user message to UI and history addMessage('user', userMessage) // Clear input field userInput.value = '' userInput.style.height = 'auto' sendBtn.disabled = true // Update button text based on streaming state if (streamToggle.checked) { sendBtn.textContent = 'Stop' await handleStreamingResponse(userMessage) } else { await handleNonStreamingResponse(userMessage) } // Reset button text sendBtn.textContent = 'Send' } // Event Listeners sendBtn.addEventListener('click', () => { if (isStreaming) { stopStream() } else { sendMessage() } }) userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (!isStreaming) { sendMessage() } } }) // Initialize on load window.onload = () => { userInput.focus() fetchModels() // Load chat history from localStorage if (chatHistory.length === 0) { // Add event listener for reset button document.getElementById('resetBtn').addEventListener('click', resetChat) } } // Reset chat history function resetChat() { // Clear chat history chatHistory = [] // Clear chat log chatLog.innerHTML = '' // Add welcome message 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.*` addMessage('assistant', welcomeMessage, true) // Save chat history to localStorage localStorage.setItem('chatHistory', JSON.stringify(chatHistory)) }