diff --git a/chat.js b/chat.js index e69de29..ad98005 100644 --- a/chat.js +++ b/chat.js @@ -0,0 +1,604 @@ +// Configuration +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' + +// Global state +let currentBackend = 'plato' +let currentModel = null +let availableModels = [] +let currentStreamController = null +let isStreaming = false +let chatHistory = [] // Initialize chat history array + +// 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 + } +} + +// 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 + + // Add message to chat history + chatHistory.push({ role, content, markdown, messageId }) + + 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) { + // 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 : [{ role: 'user', content: userMessage }], + 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) + 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) + 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) + 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) + chatLog.scrollTop = chatLog.scrollHeight + + } catch (error) { + console.error('Streaming error:', error) + if (error.name !== 'AbortError') { + updateMessageContent(contentDiv, `Error: ${ error.message }`, false) + } + } finally { + isStreaming = false + currentStreamController = null + sendBtn.disabled = false + userInput.focus() + + // 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 : [{ role: 'user', content: userMessage }], + 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 loadChatHistory() { + chatHistory.forEach(message => { + addMessage(message.role, message.content, message.markdown, message.messageId) + }) +} + +// 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 + + addMessage('user', userMessage) + userInput.value = '' + userInput.style.height = 'auto' + sendBtn.disabled = true + + if (streamToggle.checked) { + sendBtn.textContent = 'Stop' + handleStreamingResponse(userMessage) + } else { + handleNonStreamingResponse(userMessage) + } +} + +// 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 + loadChatHistory() + + // Add welcome message + setTimeout(() => { + const welcomeMessage = `# Welcome to LM Studio Chat! 🚀 + +I now support **real-time streaming responses** and **dark, readable text formatting**. + +## Features: +1. **Streaming Mode** (enabled by default) - Watch responses appear word-by-word +2. **Markdown Rendering** - Proper formatting for code, lists, tables, and more +3. **Readable Dark Text** - No more eye strain from light gray text +4. **Model Detection** - Automatically shows your loaded model + +## Try it out: +- Ask a technical question to see **code formatting** +- Type \`ls -la\` in a code block +- Request a **numbered list** or table +- Watch the streaming response in real-time! + +> *Tip: You can toggle streaming and markdown using the checkboxes below.*` + addMessage('assistant', welcomeMessage, true) + }, 500) +}