import './style2.css' 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 } }) window.autoResize = 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, repeat_penalty: 1.1, 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, repeat_penalty: 1.1, 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) } }