const BACKENDS = { plato : { prod: '/api/plato', dev : 'http://192.168.1.74:1234/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')) || [] chatHistory = chatHistory.map(message => ({ role : message.role || 'user', content : message.content || '', markdown : message.markdown || false, messageId: message.messageId || `msg-${ Date.now() }` })) 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.*` function getApiUrl() { const [backendKey, model] = currentBackendModel.split(':') const backend = BACKENDS[backendKey] return IS_PRODUCTION ? backend.prod : backend.dev } function updateBackendModelDisplay() { const [backendKey, model] = currentBackendModel.split(':') const backend = BACKENDS[backendKey] const displayText = IS_PRODUCTION ? `${ backend.prod } ? ${ backend.name }` : backend.dev document.getElementById('backendDisplay').textContent = `${ displayText } - Model: ${ model }` } async function fetchBackendModels() { try { const response = await fetch(`${ getApiUrl() }/models`) if (response.ok) { const data = await response.json() availableBackendModels = data.data.map(model => ({ backend: currentBackendModel.split(':')[0], model: model.id })) populateBackendModelSelector() if (!currentBackendModel && availableBackendModels.length > 0) { currentBackendModel = `${ availableBackendModels[0].backend }:${ availableBackendModels[0].model }` document.getElementById('backendModelSelector').value = currentBackendModel } } else { console.error('Failed to fetch models:', response.statusText) document.getElementById('backendModelSelector').innerHTML = '' } } catch (error) { console.error('Error fetching models:', error) document.getElementById('backendModelSelector').innerHTML = '' } } function populateBackendModelSelector() { const selector = document.getElementById('backendModelSelector') if (availableBackendModels.length === 0) { selector.innerHTML = '' return } selector.innerHTML = availableBackendModels.map(backendModel => `` ).join('') if (currentBackendModel) { 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') backendModelSelector.addEventListener('change', (e) => { currentBackendModel = e.target.value updateBackendModelDisplay() fetchBackendModels() }) 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) { 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 = '#2d3339' } messageDiv.appendChild(headerDiv) messageDiv.appendChild(contentDiv) chatLog.appendChild(messageDiv) chatLog.scrollTop = chatLog.scrollHeight 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.checked) { contentDiv.innerHTML = marked.parse(processedContent) if (window.hljs) { setTimeout(() => { contentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block) }) }, 0) } } else { contentDiv.textContent = newContent } } 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(abatar) 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 } 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) chatLog.appendChild(messageDiv) const cursorSpan = document.createElement('span') cursorSpan.className = 'streaming-cursor' contentDiv.appendChild(cursorSpan) try { if (!currentModel && availableModels.length > 0) { currentModel = availableModels[0].id document.getElementById('modelSelector').value = currentModel } const requestBody = { model : currentModel || 'local-model', 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) }) 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() 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.checked, 'assistant') if (cursorSpan.parentNode) { contentDiv.appendChild(cursorSpan) } chatLog.scrollTop = chatLog.scrollHeight lastUpdate = Date.now() updateScheduled = false }) } else { 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 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.parentNode) { cursorSpan.parentNode.removeChild(cursorSpan) } } } async function handleNonStreamingResponse(userMessage) { try { const typingIndicator = showTypingIndicator() if (!currentModel && availableModels.length > 0) { currentModel = availableModels[0].id document.getElementById('modelSelector').value = currentModel } const requestBody = { model : currentModel || 'local-model', 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) 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 addMessage('user', userMessage) 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() } } }) window.onload = () => { userInput.focus() fetchModels() if (chatHistory.length === 0) { document.getElementById('resetBtn').addEventListener('click', resetChat) } } function resetChat() { chatHistory = [] chatLog.innerHTML = welcomeMessage localStorage.setItem('chatHistory', JSON.stringify(chatHistory)) }