Spaces:
Running
Running
Amlan-109
feat: Initial commit of LocalAI Amlan Edition with premium branding and personalization
750bbe6
| <!-- | |
| Part of this page is based on the OpenAI Chatbot example by David Härer: | |
| https://github.com/david-haerer/chatapi | |
| MIT License Copyright (c) 2023 David Härer | |
| Copyright (c) 2024-2025 Ettore Di Giacinto | |
| Permission is hereby granted, free of charge, to any person obtaining a copy | |
| of this software and associated documentation files (the "Software"), to deal | |
| in the Software without restriction, including without limitation the rights | |
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| copies of the Software, and to permit persons to whom the Software is | |
| furnished to do so, subject to the following conditions: | |
| The above copyright notice and this permission notice shall be included in all | |
| copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| SOFTWARE. | |
| --> | |
| <html lang="en"> | |
| {{template "views/partials/head" .}} | |
| <script src="static/assets/pdf.min.js"></script> | |
| <script> | |
| // Initialize PDF.js worker | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'static/assets/pdf.worker.min.js'; | |
| </script> | |
| <script> | |
| // Initialize Alpine store - must run before Alpine processes DOM | |
| // Get context size from template | |
| var __chatContextSize = null; | |
| {{ if .ContextSize }} | |
| __chatContextSize = {{ .ContextSize }}; | |
| {{ end }} | |
| // Store gallery configs for header icon display and model info modal | |
| window.__galleryConfigs = {}; | |
| {{ $allGalleryConfigs:=.GalleryConfig }} | |
| {{ range $modelName, $galleryConfig := $allGalleryConfigs }} | |
| window.__galleryConfigs["{{$modelName}}"] = {}; | |
| {{ if $galleryConfig.Icon }} | |
| window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}"; | |
| {{ end }} | |
| {{ if $galleryConfig.Description }} | |
| window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }}; | |
| {{ end }} | |
| {{ if $galleryConfig.URLs }} | |
| window.__galleryConfigs["{{$modelName}}"].URLs = [ | |
| {{ range $idx, $url := $galleryConfig.URLs }} | |
| {{ if $idx }},{{ end }}{{ printf "%q" $url }} | |
| {{ end }} | |
| ]; | |
| {{ end }} | |
| {{ end }} | |
| // Function to initialize store | |
| function __initChatStore() { | |
| if (!window.Alpine) return; | |
| // Check for MCP mode from localStorage (set by index page) or URL parameter | |
| // Note: We don't clear localStorage here - chat.js will handle that after reading all data | |
| let initialMcpMode = false; | |
| // First check URL parameter | |
| const urlParams = new URLSearchParams(window.location.search); | |
| if (urlParams.get('mcp') === 'true') { | |
| initialMcpMode = true; | |
| } | |
| // Then check localStorage (URL param takes precedence) | |
| if (!initialMcpMode) { | |
| try { | |
| const chatData = localStorage.getItem('localai_index_chat_data'); | |
| if (chatData) { | |
| const parsed = JSON.parse(chatData); | |
| if (parsed.mcpMode === true) { | |
| initialMcpMode = true; | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Error reading MCP mode from localStorage:', e); | |
| } | |
| } | |
| if (Alpine.store("chat")) { | |
| // Store already initialized, just update context size if needed | |
| const activeChat = Alpine.store("chat").activeChat(); | |
| if (activeChat && __chatContextSize !== null) { | |
| activeChat.contextSize = __chatContextSize; | |
| } | |
| return; | |
| } | |
| // Generate unique chat ID | |
| function generateChatId() { | |
| return "chat_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9); | |
| } | |
| // Get current model from URL or input | |
| function getCurrentModel() { | |
| const modelInput = document.getElementById("chat-model"); | |
| return modelInput ? modelInput.value : ""; | |
| } | |
| Alpine.store("chat", { | |
| chats: [], | |
| activeChatId: null, | |
| chatIdCounter: 0, | |
| languages: [undefined], | |
| activeRequestIds: [], // Track chat IDs with active requests for UI reactivity | |
| // Helper to get active chat | |
| activeChat() { | |
| if (!this.activeChatId) return null; | |
| return this.chats.find(c => c.id === this.activeChatId) || null; | |
| }, | |
| // Helper to get chat by ID | |
| getChat(chatId) { | |
| return this.chats.find(c => c.id === chatId) || null; | |
| }, | |
| // Create a new chat | |
| createChat(model, systemPrompt, mcpMode) { | |
| const chatId = generateChatId(); | |
| const now = Date.now(); | |
| const chat = { | |
| id: chatId, | |
| name: "New Chat", | |
| model: model || getCurrentModel() || "", | |
| history: [], | |
| systemPrompt: systemPrompt || "", | |
| mcpMode: mcpMode || false, | |
| temperature: null, // null means use default | |
| topP: null, // null means use default | |
| topK: null, // null means use default | |
| tokenUsage: { | |
| promptTokens: 0, | |
| completionTokens: 0, | |
| totalTokens: 0, | |
| currentRequest: null | |
| }, | |
| contextSize: __chatContextSize, | |
| createdAt: now, | |
| updatedAt: now | |
| }; | |
| this.chats.push(chat); | |
| this.activeChatId = chatId; | |
| return chat; | |
| }, | |
| // Switch to a different chat | |
| switchChat(chatId) { | |
| if (this.chats.find(c => c.id === chatId)) { | |
| this.activeChatId = chatId; | |
| // Update context size if needed | |
| const chat = this.activeChat(); | |
| if (chat && __chatContextSize !== null) { | |
| chat.contextSize = __chatContextSize; | |
| } | |
| return true; | |
| } | |
| return false; | |
| }, | |
| // Delete a chat | |
| deleteChat(chatId) { | |
| const index = this.chats.findIndex(c => c.id === chatId); | |
| if (index === -1) return false; | |
| this.chats.splice(index, 1); | |
| // If deleted chat was active, switch to another or create new | |
| if (this.activeChatId === chatId) { | |
| if (this.chats.length > 0) { | |
| this.activeChatId = this.chats[0].id; | |
| } else { | |
| // Create a new default chat | |
| this.createChat(); | |
| } | |
| } | |
| return true; | |
| }, | |
| // Update chat name | |
| updateChatName(chatId, name) { | |
| const chat = this.getChat(chatId); | |
| if (chat) { | |
| chat.name = name || "New Chat"; | |
| chat.updatedAt = Date.now(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| clear() { | |
| const chat = this.activeChat(); | |
| if (chat) { | |
| chat.history.length = 0; | |
| chat.tokenUsage = { | |
| promptTokens: 0, | |
| completionTokens: 0, | |
| totalTokens: 0, | |
| currentRequest: null | |
| }; | |
| chat.updatedAt = Date.now(); | |
| } | |
| }, | |
| updateTokenUsage(usage, targetChatId = null) { | |
| // If targetChatId is provided, update that chat, otherwise use active chat | |
| // This ensures token usage updates go to the chat that initiated the request | |
| const chat = targetChatId ? this.getChat(targetChatId) : this.activeChat(); | |
| if (!chat) return; | |
| // Usage values in streaming responses are cumulative totals for the current request | |
| // We track session totals separately and only update when we see new (higher) values | |
| if (usage) { | |
| const currentRequest = chat.tokenUsage.currentRequest || { | |
| promptTokens: 0, | |
| completionTokens: 0, | |
| totalTokens: 0 | |
| }; | |
| // Check if this is a new/updated usage (values increased) | |
| const isNewUsage = | |
| (usage.prompt_tokens !== undefined && usage.prompt_tokens > currentRequest.promptTokens) || | |
| (usage.completion_tokens !== undefined && usage.completion_tokens > currentRequest.completionTokens) || | |
| (usage.total_tokens !== undefined && usage.total_tokens > currentRequest.totalTokens); | |
| if (isNewUsage) { | |
| // Update session totals: subtract old request usage, add new | |
| chat.tokenUsage.promptTokens = chat.tokenUsage.promptTokens - currentRequest.promptTokens + (usage.prompt_tokens || 0); | |
| chat.tokenUsage.completionTokens = chat.tokenUsage.completionTokens - currentRequest.completionTokens + (usage.completion_tokens || 0); | |
| chat.tokenUsage.totalTokens = chat.tokenUsage.totalTokens - currentRequest.totalTokens + (usage.total_tokens || 0); | |
| // Store current request usage | |
| chat.tokenUsage.currentRequest = { | |
| promptTokens: usage.prompt_tokens || 0, | |
| completionTokens: usage.completion_tokens || 0, | |
| totalTokens: usage.total_tokens || 0 | |
| }; | |
| chat.updatedAt = Date.now(); | |
| } | |
| } | |
| }, | |
| getRemainingTokens() { | |
| const chat = this.activeChat(); | |
| if (!chat || !chat.contextSize) return null; | |
| return Math.max(0, chat.contextSize - chat.tokenUsage.totalTokens); | |
| }, | |
| getContextUsagePercent() { | |
| const chat = this.activeChat(); | |
| if (!chat || !chat.contextSize) return null; | |
| return Math.min(100, (chat.tokenUsage.totalTokens / chat.contextSize) * 100); | |
| }, | |
| // Check if a chat has an active request (for UI indicators) | |
| hasActiveRequest(chatId) { | |
| if (!chatId) return false; | |
| // Use reactive array for Alpine.js reactivity | |
| return this.activeRequestIds.includes(chatId); | |
| }, | |
| // Update active request tracking (called from chat.js) | |
| updateActiveRequestTracking(chatId, isActive) { | |
| if (isActive) { | |
| if (!this.activeRequestIds.includes(chatId)) { | |
| this.activeRequestIds.push(chatId); | |
| } | |
| } else { | |
| const index = this.activeRequestIds.indexOf(chatId); | |
| if (index > -1) { | |
| this.activeRequestIds.splice(index, 1); | |
| } | |
| } | |
| }, | |
| add(role, content, image, audio, targetChatId = null, model = null) { | |
| // If targetChatId is provided, add to that chat, otherwise use active chat | |
| // This allows streaming to continue to the correct chat even if user switches | |
| const chat = targetChatId ? this.getChat(targetChatId) : this.activeChat(); | |
| if (!chat) return; | |
| // Determine model for this message: | |
| // - If model is explicitly provided, use it (for assistant messages with specific model) | |
| // - For user messages, use the current chat's model | |
| // - For other messages (thinking, tool_call, etc.), inherit from previous message or use chat model | |
| let messageModel = model; | |
| if (!messageModel) { | |
| if (role === "user") { | |
| // User messages always use the current chat's model | |
| messageModel = chat.model || ""; | |
| } else if (role === "assistant") { | |
| // Assistant messages use the chat's model (should be set when request is made) | |
| messageModel = chat.model || ""; | |
| } else { | |
| // For thinking, tool_call, etc., try to inherit from last assistant message, or use chat model | |
| const lastAssistant = chat.history.slice().reverse().find(m => m.role === "assistant"); | |
| messageModel = lastAssistant?.model || chat.model || ""; | |
| } | |
| } | |
| const N = chat.history.length - 1; | |
| // For thinking, reasoning, tool_call, and tool_result messages, always create a new message | |
| if (role === "thinking" || role === "reasoning" || role === "tool_call" || role === "tool_result") { | |
| let c = ""; | |
| if (role === "tool_call" || role === "tool_result") { | |
| // For tool calls and results, try to parse as JSON and format nicely | |
| try { | |
| const parsed = typeof content === 'string' ? JSON.parse(content) : content; | |
| // Format JSON with proper indentation | |
| const formatted = JSON.stringify(parsed, null, 2); | |
| c = DOMPurify.sanitize('<pre><code class="language-json">' + formatted + '</code></pre>'); | |
| } catch (e) { | |
| // If not JSON, treat as markdown | |
| const lines = content.split("\n"); | |
| lines.forEach((line) => { | |
| c += DOMPurify.sanitize(marked.parse(line)); | |
| }); | |
| } | |
| } else { | |
| // For thinking and reasoning, format as markdown | |
| const lines = content.split("\n"); | |
| lines.forEach((line) => { | |
| c += DOMPurify.sanitize(marked.parse(line)); | |
| }); | |
| } | |
| // Set expanded state: thinking and reasoning are expanded by default in non-MCP mode, collapsed in MCP mode | |
| // tool_call and tool_result are always collapsed by default | |
| const isMCPMode = chat.mcpMode || false; | |
| const shouldExpand = ((role === "thinking" || role === "reasoning") && !isMCPMode) || false; | |
| chat.history.push({ role, content, html: c, image, audio, expanded: shouldExpand, model: messageModel }); | |
| // Auto-name chat from first user message | |
| if (role === "user" && chat.name === "New Chat" && content.trim()) { | |
| const name = content.trim().substring(0, 50); | |
| chat.name = name.length < content.trim().length ? name + "..." : name; | |
| } | |
| } | |
| // For other messages, merge if same role | |
| else if (chat.history.length && chat.history[N].role === role) { | |
| chat.history[N].content += content; | |
| chat.history[N].html = DOMPurify.sanitize( | |
| marked.parse(chat.history[N].content) | |
| ); | |
| // Merge new images and audio with existing ones | |
| if (image && image.length > 0) { | |
| chat.history[N].image = [...(chat.history[N].image || []), ...image]; | |
| } | |
| if (audio && audio.length > 0) { | |
| chat.history[N].audio = [...(chat.history[N].audio || []), ...audio]; | |
| } | |
| // Preserve model if merging (don't overwrite) | |
| if (!chat.history[N].model && messageModel) { | |
| chat.history[N].model = messageModel; | |
| } | |
| } else { | |
| let c = ""; | |
| const lines = content.split("\n"); | |
| lines.forEach((line) => { | |
| c += DOMPurify.sanitize(marked.parse(line)); | |
| }); | |
| chat.history.push({ | |
| role, | |
| content, | |
| html: c, | |
| image: image || [], | |
| audio: audio || [], | |
| model: messageModel | |
| }); | |
| // Auto-name chat from first user message | |
| if (role === "user" && chat.name === "New Chat" && content.trim()) { | |
| const name = content.trim().substring(0, 50); | |
| chat.name = name.length < content.trim().length ? name + "..." : name; | |
| } | |
| } | |
| chat.updatedAt = Date.now(); | |
| // Auto-save after adding message | |
| if (typeof autoSaveChats === 'function') { | |
| autoSaveChats(); | |
| } | |
| // Scroll to bottom consistently for all messages (use #chat as it's the scrollable container) | |
| setTimeout(() => { | |
| const chatContainer = document.getElementById('chat'); | |
| if (chatContainer) { | |
| chatContainer.scrollTo({ | |
| top: chatContainer.scrollHeight, | |
| behavior: 'smooth' | |
| }); | |
| } | |
| // Also scroll thinking box if it's a thinking/reasoning message | |
| if (role === "thinking" || role === "reasoning") { | |
| if (typeof window.scrollThinkingBoxToBottom === 'function') { | |
| window.scrollThinkingBoxToBottom(); | |
| } | |
| } | |
| }, 100); | |
| const parser = new DOMParser(); | |
| const html = parser.parseFromString( | |
| chat.history[chat.history.length - 1].html, | |
| "text/html" | |
| ); | |
| const code = html.querySelectorAll("pre code"); | |
| if (!code.length) return; | |
| code.forEach((el) => { | |
| const language = el.className.split("language-")[1]; | |
| if (this.languages.includes(language)) return; | |
| const script = document.createElement("script"); | |
| script.src = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.8.0/build/languages/${language}.min.js`; | |
| script.onload = () => { | |
| // Re-highlight after language script loads | |
| if (window.hljs) { | |
| const container = document.getElementById('messages'); | |
| if (container) { | |
| container.querySelectorAll('pre code.language-json').forEach(block => { | |
| window.hljs.highlightElement(block); | |
| }); | |
| } | |
| } | |
| }; | |
| document.head.appendChild(script); | |
| this.languages.push(language); | |
| }); | |
| // Highlight code blocks immediately if hljs is available | |
| if (window.hljs) { | |
| setTimeout(() => { | |
| const container = document.getElementById('messages'); | |
| if (container) { | |
| container.querySelectorAll('pre code.language-json').forEach(block => { | |
| if (!block.classList.contains('hljs')) { | |
| window.hljs.highlightElement(block); | |
| } | |
| }); | |
| } | |
| }, 100); | |
| } | |
| }, | |
| messages() { | |
| const chat = this.activeChat(); | |
| if (!chat) return []; | |
| return chat.history.map((message) => ({ | |
| role: message.role, | |
| content: message.content, | |
| image: message.image, | |
| audio: message.audio, | |
| })); | |
| }, | |
| // Getter for active chat history to ensure reactivity | |
| get activeHistory() { | |
| const chat = this.activeChat(); | |
| return chat ? chat.history : []; | |
| }, | |
| }); | |
| } | |
| // Register listener immediately (before Alpine loads) | |
| document.addEventListener("alpine:init", __initChatStore); | |
| // Also try immediately in case Alpine is already loaded | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', function() { | |
| if (window.Alpine) __initChatStore(); | |
| }); | |
| } else { | |
| // DOM already loaded, try immediately | |
| if (window.Alpine) __initChatStore(); | |
| } | |
| // Function to update model and context size when model selector changes | |
| window.updateModelAndContextSize = function(selectElement) { | |
| if (!window.Alpine || !Alpine.store("chat")) { | |
| // Fallback: navigate to new model URL | |
| window.location = selectElement.value; | |
| return; | |
| } | |
| const chatStore = Alpine.store("chat"); | |
| const activeChat = chatStore.activeChat(); | |
| if (!activeChat) { | |
| window.location = selectElement.value; | |
| return; | |
| } | |
| // Get the selected option | |
| const selectedOption = selectElement.options[selectElement.selectedIndex]; | |
| const modelName = selectElement.value.replace('chat/', ''); | |
| // Update model name | |
| activeChat.model = modelName; | |
| activeChat.updatedAt = Date.now(); | |
| // Update model info modal with new model | |
| if (window.updateModelInfoModal) { | |
| window.updateModelInfoModal(modelName); | |
| } | |
| // Get context size from data attribute | |
| let contextSize = null; | |
| if (selectedOption.dataset.contextSize) { | |
| contextSize = parseInt(selectedOption.dataset.contextSize); | |
| if (!isNaN(contextSize)) { | |
| activeChat.contextSize = contextSize; | |
| } else { | |
| activeChat.contextSize = null; | |
| } | |
| } else { | |
| // No context size available, set to null | |
| activeChat.contextSize = null; | |
| } | |
| // Check MCP availability from data attribute | |
| const hasMCP = selectedOption.getAttribute('data-has-mcp') === 'true'; | |
| if (!hasMCP) { | |
| // If model doesn't support MCP, disable MCP mode | |
| activeChat.mcpMode = false; | |
| } | |
| // Note: We don't enable MCP mode automatically, user must toggle it | |
| // Update the hidden input for consistency | |
| const contextSizeInput = document.getElementById("chat-model"); | |
| if (contextSizeInput) { | |
| contextSizeInput.value = modelName; | |
| if (contextSize) { | |
| contextSizeInput.setAttribute('data-context-size', contextSize); | |
| } else { | |
| contextSizeInput.removeAttribute('data-context-size'); | |
| } | |
| if (hasMCP) { | |
| contextSizeInput.setAttribute('data-has-mcp', 'true'); | |
| } else { | |
| contextSizeInput.setAttribute('data-has-mcp', 'false'); | |
| } | |
| } | |
| // Update model selector to reflect the change (ensure it stays in sync) | |
| // Note: We don't dispatch a change event here to avoid infinite loop | |
| // The selector is already updated via user interaction or programmatic change | |
| const modelSelector = document.getElementById('modelSelector'); | |
| if (modelSelector) { | |
| // Find and select the option matching the model | |
| const optionValue = 'chat/' + modelName; | |
| for (let i = 0; i < modelSelector.options.length; i++) { | |
| if (modelSelector.options[i].value === optionValue) { | |
| // Only update if it's different to avoid unnecessary updates | |
| if (modelSelector.selectedIndex !== i) { | |
| modelSelector.selectedIndex = i; | |
| } | |
| break; | |
| } | |
| } | |
| // Don't dispatch change event here - it would cause infinite recursion | |
| // The selector is already in sync with the model | |
| } | |
| // Trigger MCP availability check in Alpine component | |
| // The MCP toggle component will reactively check the data-has-mcp attribute | |
| // Save to storage | |
| if (typeof autoSaveChats === 'function') { | |
| autoSaveChats(); | |
| } | |
| // Update UI - this will refresh the statistics display | |
| if (typeof updateUIForActiveChat === 'function') { | |
| updateUIForActiveChat(); | |
| } | |
| } | |
| </script> | |
| <script defer src="static/chat.js"></script> | |
| {{ $allGalleryConfigs:=.GalleryConfig }} | |
| {{ $model:=.Model}} | |
| <body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex flex-col h-screen" x-data="{ sidebarOpen: true, showClearAlert: false }"> | |
| {{template "views/partials/navbar" .}} | |
| <!-- Main container with sidebar toggle --> | |
| <div class="flex flex-1 overflow-hidden relative"> | |
| <!-- Sidebar --> | |
| <div | |
| class="sidebar bg-[var(--color-bg-secondary)] fixed top-14 bottom-0 left-0 w-56 transform transition-transform duration-300 ease-in-out z-30 border-r border-[var(--color-bg-primary)] overflow-y-auto" | |
| :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"> | |
| <div class="p-3 flex justify-between items-center border-b border-[var(--color-bg-primary)]"> | |
| <div class="flex items-center gap-2"> | |
| <h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Settings</h2> | |
| <a | |
| href="https://localai.io/features/text-generation/" | |
| target="_blank" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs" | |
| title="Documentation"> | |
| <i class="fas fa-book"></i> | |
| </a> | |
| </div> | |
| <button | |
| @click="sidebarOpen = false" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus:outline-none text-xs" | |
| title="Hide sidebar"> | |
| <i class="fa-solid fa-chevron-left"></i> | |
| </button> | |
| </div> | |
| <!-- Sidebar content --> | |
| <div class="p-3 space-y-3"> | |
| <!-- Model selection - Compact --> | |
| <div class="space-y-1.5"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label> | |
| <div class="flex items-center gap-1 flex-shrink-0"> | |
| <!-- Info button - reactive to active chat model --> | |
| <template x-if="$store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model]"> | |
| <button | |
| data-twe-ripple-init | |
| data-twe-ripple-color="light" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]" | |
| data-modal-target="model-info-modal" | |
| data-modal-toggle="model-info-modal" | |
| :data-model-name="$store.chat.activeChat().model" | |
| @click="if (window.updateModelInfoModal) { window.updateModelInfoModal($store.chat.activeChat().model, true); }" | |
| title="Model Information"> | |
| <i class="fas fa-info-circle"></i> | |
| </button> | |
| </template> | |
| <!-- Fallback info button for initial model from server --> | |
| <template x-if="(!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}']"> | |
| <button | |
| data-twe-ripple-init | |
| data-twe-ripple-color="light" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]" | |
| data-modal-target="model-info-modal" | |
| data-modal-toggle="model-info-modal" | |
| data-model-name="{{$model}}" | |
| @click="if (window.updateModelInfoModal) { window.updateModelInfoModal('{{$model}}', true); }" | |
| title="Model Information"> | |
| <i class="fas fa-info-circle"></i> | |
| </button> | |
| </template> | |
| <!-- Edit button - reactive to active chat model --> | |
| <template x-if="$store.chat.activeChat() && $store.chat.activeChat().model"> | |
| <a :href="'/models/edit/' + $store.chat.activeChat().model" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]" | |
| title="Edit Model Configuration"> | |
| <i class="fas fa-edit"></i> | |
| </a> | |
| </template> | |
| <!-- Fallback edit button for initial model from server --> | |
| <template x-if="!$store.chat.activeChat() || !$store.chat.activeChat().model"> | |
| {{ if $model }} | |
| <a href="/models/edit/{{$model}}" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]" | |
| title="Edit Model Configuration"> | |
| <i class="fas fa-edit"></i> | |
| </a> | |
| {{ end }} | |
| </template> | |
| </div> | |
| </div> | |
| <select | |
| id="modelSelector" | |
| class="input w-full p-1.5 text-xs" | |
| onchange="updateModelAndContextSize(this);" | |
| > | |
| <option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option> | |
| {{ range .ModelsConfig }} | |
| {{ $cfg := . }} | |
| {{ $hasMCP := or (ne $cfg.MCP.Servers "") (ne $cfg.MCP.Stdio "") }} | |
| {{ range .KnownUsecaseStrings }} | |
| {{ if eq . "FLAG_CHAT" }} | |
| <option | |
| value="chat/{{$cfg.Name}}" | |
| {{ if eq $cfg.Name $model }} selected {{end}} | |
| {{ if $cfg.LLMConfig.ContextSize }}data-context-size="{{$cfg.LLMConfig.ContextSize}}"{{ end }} | |
| data-has-mcp="{{if $hasMCP}}true{{else}}false{{end}}" | |
| class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]" | |
| > | |
| {{$cfg.Name}} | |
| </option> | |
| {{ end }} | |
| {{ end }} | |
| {{ end }} | |
| {{ range .ModelsWithoutConfig }} | |
| <option | |
| value="chat/{{.}}" | |
| {{ if eq . $model }} selected {{ end }} | |
| class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]" | |
| > | |
| {{.}} | |
| </option> | |
| {{end}} | |
| </select> | |
| </div> | |
| <!-- Chat List --> | |
| <div class="space-y-2" x-data="{ | |
| editingChatId: null, | |
| editingName: '', | |
| searchQuery: '', | |
| filteredChats() { | |
| let chats = $store.chat.chats; | |
| // Sort chats with stable ordering to prevent flickering during parallel streaming | |
| chats = [...chats].sort((a, b) => { | |
| const aActive = $store.chat.hasActiveRequest(a.id); | |
| const bActive = $store.chat.hasActiveRequest(b.id); | |
| // Prioritize active chats at the top | |
| if (aActive && !bActive) return -1; | |
| if (!aActive && bActive) return 1; | |
| // For active chats, use createdAt to maintain stable order (prevent flickering) | |
| // This ensures active chats don't reorder among themselves as they update | |
| if (aActive && bActive) { | |
| const createdA = a.createdAt || 0; | |
| const createdB = b.createdAt || 0; | |
| return createdB - createdA; // Newer active chats first, but stable | |
| } | |
| // For inactive chats, sort by updatedAt (most recent first) | |
| const timeA = a.updatedAt || a.createdAt || 0; | |
| const timeB = b.updatedAt || b.createdAt || 0; | |
| if (timeB !== timeA) { | |
| return timeB - timeA; | |
| } | |
| // Tiebreaker: use createdAt | |
| const createdA = a.createdAt || 0; | |
| const createdB = b.createdAt || 0; | |
| return createdB - createdA; | |
| }); | |
| if (!this.searchQuery || !this.searchQuery.trim()) { | |
| return chats; | |
| } | |
| const query = this.searchQuery.toLowerCase().trim(); | |
| return chats.filter(chat => { | |
| // Search in chat name | |
| const nameMatch = (chat.name || 'New Chat').toLowerCase().includes(query); | |
| // Search in message content | |
| const contentMatch = chat.history && chat.history.some(message => { | |
| if (message.content) { | |
| let contentText = ''; | |
| if (typeof message.content === 'string') { | |
| // Remove HTML tags for searching | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = message.content; | |
| contentText = (tempDiv.textContent || tempDiv.innerText || '').toLowerCase(); | |
| } else if (Array.isArray(message.content)) { | |
| // Handle array content (multimodal) | |
| contentText = message.content | |
| .filter(item => item.type === 'text' && item.text) | |
| .map(item => item.text) | |
| .join(' ') | |
| .toLowerCase(); | |
| } | |
| return contentText.includes(query); | |
| } | |
| return false; | |
| }); | |
| return nameMatch || contentMatch; | |
| }); | |
| } | |
| }"> | |
| <div class="flex items-center justify-between"> | |
| <h2 class="text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wide">Chats</h2> | |
| <div class="flex items-center gap-1"> | |
| <button | |
| @click="createNewChat()" | |
| class="text-[var(--color-primary)] hover:text-[var(--color-accent)] transition-colors text-xs p-1" | |
| title="New Chat"> | |
| <i class="fa-solid fa-plus"></i> | |
| </button> | |
| <button | |
| @click="if (confirm('Delete all chats? This cannot be undone.')) { bulkDeleteChats({deleteAll: true}); }" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-error)] transition-colors text-xs p-1" | |
| title="Delete all chats" | |
| x-show="$store.chat.chats.length > 0"> | |
| <i class="fa-solid fa-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Search Input --> | |
| <div class="relative"> | |
| <input | |
| type="text" | |
| x-model="searchQuery" | |
| placeholder="Search conversations..." | |
| class="w-full bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] border border-[var(--color-bg-secondary)] focus:border-[var(--color-primary-border)] focus:ring-1 focus:ring-[var(--color-primary)]/50 rounded py-1.5 pr-2 text-xs placeholder-[var(--color-text-secondary)]" | |
| style="padding-left: 2rem !important;" | |
| /> | |
| <i class="fa-solid fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)] text-xs pointer-events-none z-10"></i> | |
| <button | |
| x-show="searchQuery.length > 0" | |
| @click="searchQuery = ''" | |
| class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] text-xs" | |
| title="Clear search"> | |
| <i class="fa-solid fa-times"></i> | |
| </button> | |
| </div> | |
| <!-- Chat List --> | |
| <div class="max-h-80 overflow-y-auto space-y-1 border border-[var(--color-bg-secondary)] rounded p-1.5"> | |
| <template x-for="chat in filteredChats()" :key="chat.id"> | |
| <div | |
| class="flex items-center justify-between p-1.5 rounded hover:bg-[var(--color-bg-secondary)] transition-colors cursor-pointer group" | |
| :class="{ 'bg-[var(--color-primary)]/20 border border-[var(--color-primary-border)]/40': $store.chat.activeChatId === chat.id }" | |
| @click="if (editingChatId !== chat.id) switchChat(chat.id)" | |
| > | |
| <div class="flex-1 min-w-0"> | |
| <template x-if="editingChatId === chat.id"> | |
| <input | |
| type="text" | |
| x-model="editingName" | |
| @blur="updateChatName(chat.id, editingName); editingChatId = null" | |
| @keydown.enter="updateChatName(chat.id, editingName); editingChatId = null" | |
| @keydown.escape="editingChatId = null" | |
| class="w-full bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] border border-[var(--color-primary-border)] rounded px-1.5 py-0.5 text-xs" | |
| x-ref="editInput" | |
| x-effect="if (editingChatId === chat.id) { $refs.editInput?.focus(); editingName = chat.name; }" | |
| /> | |
| </template> | |
| <template x-if="editingChatId !== chat.id"> | |
| <div class="flex items-center space-x-1.5"> | |
| <!-- Loading indicator for active requests --> | |
| <div x-show="$store.chat.hasActiveRequest(chat.id)" | |
| class="flex-shrink-0"> | |
| <i class="fa-solid fa-spinner fa-spin text-[var(--color-primary)] text-[10px]"></i> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div | |
| class="text-xs font-medium text-[var(--color-text-primary)] truncate" | |
| @dblclick="editingChatId = chat.id; editingName = chat.name" | |
| x-text="chat.name || 'New Chat'" | |
| ></div> | |
| <div class="flex items-center gap-1.5"> | |
| <div class="text-[10px] text-[var(--color-text-secondary)] truncate" x-text="getLastMessagePreview(chat)"></div> | |
| <span class="text-[9px] text-[var(--color-text-secondary)]/60" x-text="formatChatDate(chat.updatedAt || chat.createdAt)"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button | |
| @click.stop="editingChatId = chat.id; editingName = chat.name" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-[10px] p-0.5" | |
| title="Rename chat"> | |
| <i class="fa-solid fa-edit"></i> | |
| </button> | |
| <button | |
| @click.stop="if (confirm('Delete this chat?')) deleteChat(chat.id)" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-error)] transition-colors text-[10px] p-0.5" | |
| title="Delete chat" | |
| x-show="$store.chat.chats.length > 1"> | |
| <i class="fa-solid fa-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </template> | |
| <div x-show="filteredChats().length === 0 && $store.chat.chats.length > 0" class="text-xs text-[var(--color-text-secondary)] text-center py-2"> | |
| No conversations match your search | |
| </div> | |
| <div x-show="$store.chat.chats.length === 0" class="text-xs text-[var(--color-text-secondary)] text-center py-2"> | |
| No chats yet | |
| </div> | |
| </div> | |
| </div> | |
| <div x-data="{ showPromptForm: false, showParamsForm: false }" class="space-y-2"> | |
| <!-- MCP Toggle - Compact (shown dynamically based on model support) --> | |
| <div x-data="{ | |
| mcpAvailable: false, | |
| checkMCP() { | |
| const modelSelector = document.getElementById('modelSelector'); | |
| if (!modelSelector) { | |
| this.mcpAvailable = false; | |
| return; | |
| } | |
| const selectedOption = modelSelector.options[modelSelector.selectedIndex]; | |
| if (!selectedOption) { | |
| this.mcpAvailable = false; | |
| return; | |
| } | |
| const hasMCP = selectedOption.getAttribute('data-has-mcp') === 'true'; | |
| this.mcpAvailable = hasMCP; | |
| // If model doesn't support MCP, disable MCP mode | |
| const activeChat = $store.chat.activeChat(); | |
| if (activeChat && !hasMCP) { | |
| activeChat.mcpMode = false; | |
| } | |
| }, | |
| init() { | |
| this.checkMCP(); | |
| // Watch for model selector changes | |
| const modelSelector = document.getElementById('modelSelector'); | |
| if (modelSelector) { | |
| modelSelector.addEventListener('change', () => { | |
| this.checkMCP(); | |
| }); | |
| } | |
| // Also watch for active chat changes (when switching chats) | |
| this.$watch('$store.chat.activeChatId', () => { | |
| this.checkMCP(); | |
| }); | |
| } | |
| }" x-show="mcpAvailable"> | |
| <div class="flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"> | |
| <span><i class="fa-solid fa-plug mr-1.5 text-[var(--color-primary)]"></i> MCP Mode</span> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="mcp-toggle" class="sr-only peer" :checked="$store.chat.activeChat()?.mcpMode || false" @change="if ($store.chat.activeChat()) { $store.chat.activeChat().mcpMode = $event.target.checked; autoSaveChats(); }"> | |
| <div class="w-9 h-5 bg-[var(--color-bg-primary)] peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[var(--color-primary)]/30 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-[var(--color-bg-secondary)] after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div> | |
| </label> | |
| </div> | |
| <!-- MCP Mode Notification - Compact --> | |
| <div x-show="$store.chat.activeChat()?.mcpMode" class="p-2 bg-[var(--color-primary)]/10 border border-[var(--color-primary-border)]/30 rounded text-[var(--color-text-secondary)] text-[10px]"> | |
| <div class="flex items-start space-x-1.5"> | |
| <i class="fa-solid fa-info-circle text-[var(--color-primary)] mt-0.5"></i> | |
| <div> | |
| <p class="font-medium text-[var(--color-text-primary)] mb-0.5">Non-streaming Mode</p> | |
| <p class="text-[var(--color-text-secondary)]">Full processing before display (may take up to 5 minutes on CPU).</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <button | |
| @click="showPromptForm = !showPromptForm" | |
| class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors" | |
| > | |
| <span><i class="fa-solid fa-message mr-1.5 text-[var(--color-primary)]"></i> System Prompt</span> | |
| <i :class="showPromptForm ? 'fa-chevron-up' : 'fa-chevron-down'" class="fa-solid text-[10px]"></i> | |
| </button> | |
| <div x-show="showPromptForm" x-data="{ | |
| showToast: false, | |
| previousPrompt: $store.chat.activeChat()?.systemPrompt || '', | |
| isUpdated() { | |
| const currentPrompt = $store.chat.activeChat()?.systemPrompt || ''; | |
| if (this.previousPrompt !== currentPrompt) { | |
| this.showToast = true; | |
| this.previousPrompt = currentPrompt; | |
| if ($store.chat.activeChat()) { | |
| $store.chat.activeChat().systemPrompt = currentPrompt; | |
| $store.chat.activeChat().updatedAt = Date.now(); | |
| autoSaveChats(); | |
| } | |
| setTimeout(() => {this.showToast = false;}, 2000); | |
| } | |
| } | |
| }" class="p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)]"> | |
| <form id="system_prompt" @submit.prevent="isUpdated" class="flex flex-col space-y-1.5"> | |
| <textarea | |
| type="text" | |
| id="systemPrompt" | |
| class="input" | |
| name="systemPrompt" | |
| class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] border border-[var(--color-bg-secondary)] focus:border-[var(--color-primary-border)] focus:ring-1 focus:ring-[var(--color-primary)] focus:ring-opacity-50 rounded p-1.5 text-xs appearance-none min-h-20 placeholder-[var(--color-text-secondary)]" | |
| placeholder="System prompt" | |
| :value="$store.chat.activeChat()?.systemPrompt || ''" | |
| @input="if ($store.chat.activeChat()) { $store.chat.activeChat().systemPrompt = $event.target.value; $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| @change="if ($store.chat.activeChat()) { $store.chat.activeChat().systemPrompt = $event.target.value; $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| ></textarea> | |
| <div | |
| x-show="showToast" | |
| x-transition | |
| class="text-[var(--color-success)] px-2 py-1 text-xs text-center bg-[var(--color-success-light)] border border-[var(--color-success-light)] rounded" | |
| > | |
| Updated! | |
| </div> | |
| <button | |
| type="submit" | |
| class="px-2 py-1 text-xs rounded text-[var(--color-bg-primary)] bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 transition-colors font-medium" | |
| > | |
| Save | |
| </button> | |
| </form> | |
| </div> | |
| <!-- Generation Parameters --> | |
| <button | |
| @click="showParamsForm = !showParamsForm" | |
| class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors" | |
| > | |
| <span><i class="fa-solid fa-sliders mr-1.5 text-[var(--color-primary)]"></i> Generation Parameters</span> | |
| <i :class="showParamsForm ? 'fa-chevron-up' : 'fa-chevron-down'" class="fa-solid text-[10px]"></i> | |
| </button> | |
| <div x-show="showParamsForm" class="p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)] overflow-hidden"> | |
| <div class="flex flex-col space-y-3"> | |
| <!-- Temperature --> | |
| <div class="space-y-1 min-w-0"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <label class="text-xs text-[var(--color-text-secondary)] flex-shrink-0">Temperature</label> | |
| <span class="text-xs text-[var(--color-text-primary)] font-medium flex-shrink-0" x-text="($store.chat.activeChat()?.temperature !== null && $store.chat.activeChat()?.temperature !== undefined) ? $store.chat.activeChat().temperature.toFixed(2) : 'Default'"></span> | |
| </div> | |
| <div class="flex items-center gap-2 min-w-0"> | |
| <input | |
| type="range" | |
| min="0" | |
| max="2" | |
| step="0.01" | |
| class="flex-1 min-w-0 h-1.5 bg-[var(--color-bg-primary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)]" | |
| :value="$store.chat.activeChat()?.temperature ?? 1.0" | |
| @input="if ($store.chat.activeChat()) { $store.chat.activeChat().temperature = parseFloat($event.target.value); $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| /> | |
| <button | |
| @click="if ($store.chat.activeChat()) { $store.chat.activeChat().temperature = null; $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs px-2 py-1 flex-shrink-0" | |
| title="Reset to default" | |
| x-show="$store.chat.activeChat()?.temperature !== null && $store.chat.activeChat()?.temperature !== undefined" | |
| > | |
| <i class="fa-solid fa-rotate-left"></i> | |
| </button> | |
| </div> | |
| <p class="text-[10px] text-[var(--color-text-secondary)]">Controls randomness (0 = deterministic, 2 = very creative)</p> | |
| </div> | |
| <!-- Top P --> | |
| <div class="space-y-1 min-w-0"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <label class="text-xs text-[var(--color-text-secondary)] flex-shrink-0">Top P</label> | |
| <span class="text-xs text-[var(--color-text-primary)] font-medium flex-shrink-0" x-text="($store.chat.activeChat()?.topP !== null && $store.chat.activeChat()?.topP !== undefined) ? $store.chat.activeChat().topP.toFixed(2) : 'Default'"></span> | |
| </div> | |
| <div class="flex items-center gap-2 min-w-0"> | |
| <input | |
| type="range" | |
| min="0" | |
| max="1" | |
| step="0.01" | |
| class="flex-1 min-w-0 h-1.5 bg-[var(--color-bg-primary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)]" | |
| :value="$store.chat.activeChat()?.topP ?? 0.9" | |
| @input="if ($store.chat.activeChat()) { $store.chat.activeChat().topP = parseFloat($event.target.value); $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| /> | |
| <button | |
| @click="if ($store.chat.activeChat()) { $store.chat.activeChat().topP = null; $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs px-2 py-1 flex-shrink-0" | |
| title="Reset to default" | |
| x-show="$store.chat.activeChat()?.topP !== null && $store.chat.activeChat()?.topP !== undefined" | |
| > | |
| <i class="fa-solid fa-rotate-left"></i> | |
| </button> | |
| </div> | |
| <p class="text-[10px] text-[var(--color-text-secondary)]">Nucleus sampling threshold (0-1)</p> | |
| </div> | |
| <!-- Top K --> | |
| <div class="space-y-1 min-w-0"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <label class="text-xs text-[var(--color-text-secondary)] flex-shrink-0">Top K</label> | |
| <span class="text-xs text-[var(--color-text-primary)] font-medium flex-shrink-0" x-text="($store.chat.activeChat()?.topK !== null && $store.chat.activeChat()?.topK !== undefined) ? $store.chat.activeChat().topK : 'Default'"></span> | |
| </div> | |
| <div class="flex items-center gap-2 min-w-0"> | |
| <input | |
| type="range" | |
| min="0" | |
| max="100" | |
| step="1" | |
| class="flex-1 min-w-0 h-1.5 bg-[var(--color-bg-primary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)]" | |
| :value="$store.chat.activeChat()?.topK ?? 40" | |
| @input="if ($store.chat.activeChat()) { $store.chat.activeChat().topK = parseInt($event.target.value); $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| /> | |
| <button | |
| @click="if ($store.chat.activeChat()) { $store.chat.activeChat().topK = null; $store.chat.activeChat().updatedAt = Date.now(); autoSaveChats(); }" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs px-2 py-1 flex-shrink-0" | |
| title="Reset to default" | |
| x-show="$store.chat.activeChat()?.topK !== null && $store.chat.activeChat()?.topK !== undefined" | |
| > | |
| <i class="fa-solid fa-rotate-left"></i> | |
| </button> | |
| </div> | |
| <p class="text-[10px] text-[var(--color-text-secondary)]">Limit sampling to top K tokens (0 = disabled)</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main chat container (shifts with sidebar) --> | |
| <div | |
| class="flex-1 flex flex-col transition-all duration-300 ease-in-out" | |
| :class="sidebarOpen ? 'ml-56' : 'ml-0'"> | |
| <!-- Chat header with toggle button --> | |
| <div class="border-b border-[var(--color-bg-secondary)] p-4 flex items-center justify-between"> | |
| <div class="flex items-center"> | |
| <!-- Sidebar toggle button moved to be the first element in the header and with clear styling --> | |
| <button | |
| @click="sidebarOpen = !sidebarOpen" | |
| class="mr-4 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus:outline-none bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-secondary)]/80 p-2 rounded transition-colors" | |
| style="min-width: 36px;" | |
| title="Toggle settings"> | |
| <i class="fa-solid" :class="sidebarOpen ? 'fa-chevron-left' : 'fa-bars'"></i> | |
| </button> | |
| <div class="flex items-center"> | |
| <i class="fa-solid fa-comments mr-2 text-[var(--color-primary)]"></i> | |
| <!-- Model icon - reactive to active chat --> | |
| <template x-if="$store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model] && window.__galleryConfigs[$store.chat.activeChat().model].Icon"> | |
| <img :src="window.__galleryConfigs[$store.chat.activeChat().model].Icon" class="rounded-lg w-8 h-8 mr-2"> | |
| </template> | |
| <!-- Fallback icon for initial model from server (when no active chat yet) --> | |
| <template x-if="(!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}'] && window.__galleryConfigs['{{$model}}'].Icon"> | |
| <img :src="window.__galleryConfigs['{{$model}}'].Icon" class="rounded-lg w-8 h-8 mr-2"> | |
| </template> | |
| <h1 class="text-lg font-semibold text-[var(--color-text-primary)]"> | |
| Chat | |
| <template x-if="$store.chat.activeChat() && $store.chat.activeChat().model"> | |
| <span x-text="' with ' + $store.chat.activeChat().model"></span> | |
| </template> | |
| <template x-if="!$store.chat.activeChat() || !$store.chat.activeChat().model"> | |
| {{ if .Model }}<span> with {{.Model}}</span>{{ end }} | |
| </template> | |
| </h1> | |
| <!-- Loading indicator next to model name --> | |
| <div id="header-loading-indicator" class="ml-3 text-[var(--color-primary)]" style="display: none;"> | |
| <i class="fas fa-spinner fa-spin text-sm"></i> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button | |
| @click="if (confirm('Clear all messages from this conversation? This action cannot be undone.')) { $store.chat.clear(); showClearAlert = true; setTimeout(() => showClearAlert = false, 3000); }" | |
| id="clear" | |
| title="Clear current chat history" | |
| class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors p-2 rounded hover:bg-[var(--color-bg-secondary)]" | |
| x-show="$store.chat.activeChat() && ($store.chat.activeChat()?.history?.length || 0) > 0"> | |
| <i class="fa-solid fa-broom"></i> | |
| </button> | |
| </div> | |
| <!-- Clear Chat Alert --> | |
| <div x-show="showClearAlert" | |
| x-transition:enter="transition ease-out duration-300" | |
| x-transition:enter-start="opacity-0 translate-y-2" | |
| x-transition:enter-end="opacity-100 translate-y-0" | |
| x-transition:leave="transition ease-in duration-200" | |
| x-transition:leave-start="opacity-100" | |
| x-transition:leave-end="opacity-0" | |
| class="fixed top-20 right-4 z-50 max-w-sm pointer-events-none"> | |
| <div class="bg-[var(--color-primary)]/20 border border-[var(--color-primary-border)]/40 rounded-lg p-3 shadow-lg backdrop-blur-sm"> | |
| <div class="flex items-center gap-2"> | |
| <i class="fa-solid fa-check-circle text-[var(--color-primary)]"></i> | |
| <span class="text-sm text-[var(--color-text-primary)] font-medium">Chat history cleared successfully</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat messages area --> | |
| <div class="flex-1 p-4 overflow-auto" id="chat"> | |
| <p id="usage" x-show="!$store.chat.activeChat() || ($store.chat.activeChat()?.history?.length || 0) === 0" class="text-[var(--color-text-secondary)]"> | |
| Start chatting with the AI by typing a prompt in the input field below and pressing Enter.<br> | |
| <ul class="list-disc list-inside mt-2 space-y-1"> | |
| <li>For models that support images, you can upload an image by clicking the <i class="fa-solid fa-image text-[var(--color-primary)]"></i> icon.</li> | |
| <li>For models that support audio, you can upload an audio file by clicking the <i class="fa-solid fa-microphone text-[var(--color-primary)]"></i> icon.</li> | |
| <li>To send a text, markdown or PDF file, click the <i class="fa-solid fa-file text-[var(--color-primary)]"></i> icon.</li> | |
| </ul> | |
| </p> | |
| <div id="messages" class="max-w-3xl mx-auto space-y-2" :key="$store.chat.activeChatId"> | |
| <template x-for="(message, index) in $store.chat.activeHistory" :key="index"> | |
| <div> | |
| <!-- Reasoning/Thinking messages appear first (before assistant) - collapsible in MCP mode --> | |
| <template x-if="message.role === 'reasoning' || message.role === 'thinking'"> | |
| <div class="flex items-start space-x-2 mb-1"> | |
| <div class="flex flex-col flex-1"> | |
| <div class="p-2 flex-1 rounded-lg bg-[var(--color-primary)]/10 text-[var(--color-text-secondary)] border border-[var(--color-primary-border)]/30"> | |
| <button | |
| @click="message.expanded = !message.expanded" | |
| class="w-full flex items-center justify-between text-left hover:bg-[var(--color-primary)]/20 rounded p-2 transition-colors" | |
| > | |
| <div class="flex items-center space-x-2"> | |
| <i :class="message.role === 'thinking' ? 'fa-solid fa-brain' : 'fa-solid fa-lightbulb'" class="text-[var(--color-primary)]"></i> | |
| <span class="text-xs font-semibold text-[var(--color-primary)]" x-text="message.role === 'thinking' ? 'Thinking' : 'Reasoning'"></span> | |
| <span class="text-xs text-[var(--color-text-secondary)]" x-show="message.content && message.content.length > 0" x-text="'(' + Math.ceil(message.content.length / 100) + ' lines)'"></span> | |
| </div> | |
| <i | |
| class="fa-solid text-[var(--color-primary)] transition-transform text-xs" | |
| :class="message.expanded ? 'fa-chevron-up' : 'fa-chevron-down'" | |
| ></i> | |
| </button> | |
| <div | |
| x-show="message.expanded" | |
| x-transition | |
| class="mt-2 pt-2 border-t border-[var(--color-primary-border)]/20" | |
| > | |
| <div | |
| class="text-[var(--color-text-primary)] text-sm max-h-96 overflow-auto" | |
| x-html="message.html" | |
| data-thinking-box | |
| x-effect="if (message.expanded && message.html) { setTimeout(() => { if ($el.scrollHeight > $el.clientHeight) { $el.scrollTo({ top: $el.scrollHeight, behavior: 'smooth' }); } }, 50); }" | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Tool calls (collapsible) --> | |
| <template x-if="message.role === 'tool_call'"> | |
| <div class="flex items-start space-x-2 mb-1 min-w-0"> | |
| <div class="flex flex-col flex-1 min-w-0"> | |
| <div class="p-2 flex-1 rounded-lg bg-[var(--color-accent)]/10 text-[var(--color-text-secondary)] border border-[var(--color-accent-border)]/30 min-w-0"> | |
| <button | |
| @click="message.expanded = !message.expanded" | |
| class="w-full flex items-center justify-between text-left hover:bg-[var(--color-accent)]/20 rounded p-2 transition-colors min-w-0" | |
| > | |
| <div class="flex items-center space-x-2 min-w-0 flex-1"> | |
| <i class="fa-solid fa-wrench text-[var(--color-accent)] flex-shrink-0"></i> | |
| <span class="text-xs font-semibold text-[var(--color-accent)] flex-shrink-0">Tool Call</span> | |
| <span class="text-xs text-[var(--color-text-secondary)] truncate min-w-0" x-text="getToolName(message.content)"></span> | |
| </div> | |
| <i | |
| class="fa-solid text-[var(--color-accent)] transition-transform text-xs flex-shrink-0" | |
| :class="message.expanded ? 'fa-chevron-up' : 'fa-chevron-down'" | |
| ></i> | |
| </button> | |
| <div | |
| x-show="message.expanded" | |
| x-transition | |
| class="mt-2 pt-2 border-t border-[var(--color-accent-border)]/20 min-w-0" | |
| > | |
| <div class="text-[var(--color-text-primary)] text-xs max-h-96 overflow-x-auto overflow-y-auto tool-call-content w-full min-w-0" | |
| x-html="message.html" | |
| x-effect="if (message.expanded && window.hljs) { setTimeout(() => { $el.querySelectorAll('pre code.language-json').forEach(block => { if (!block.classList.contains('hljs')) window.hljs.highlightElement(block); }); }, 50); }"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Tool results (collapsible) --> | |
| <template x-if="message.role === 'tool_result'"> | |
| <div class="flex items-start space-x-2 mb-1 min-w-0"> | |
| <div class="flex flex-col flex-1 min-w-0"> | |
| <div class="p-2 flex-1 rounded-lg bg-[var(--color-success)]/10 text-[var(--color-text-secondary)] border border-[var(--color-success)]/30 min-w-0"> | |
| <button | |
| @click="message.expanded = !message.expanded" | |
| class="w-full flex items-center justify-between text-left hover:bg-[var(--color-success)]/20 rounded p-2 transition-colors min-w-0" | |
| > | |
| <div class="flex items-center space-x-2 min-w-0 flex-1"> | |
| <i class="fa-solid fa-check-circle text-[var(--color-success)] flex-shrink-0"></i> | |
| <span class="text-xs font-semibold text-[var(--color-success)] flex-shrink-0">Tool Result</span> | |
| <span class="text-xs text-[var(--color-text-secondary)] truncate min-w-0" x-text="getToolName(message.content) || 'Success'"></span> | |
| </div> | |
| <i | |
| class="fa-solid text-[var(--color-success)] transition-transform text-xs flex-shrink-0" | |
| :class="message.expanded ? 'fa-chevron-up' : 'fa-chevron-down'" | |
| ></i> | |
| </button> | |
| <div | |
| x-show="message.expanded" | |
| x-transition | |
| class="mt-2 pt-2 border-t border-[var(--color-success)]/20 min-w-0" | |
| > | |
| <div class="text-[var(--color-text-primary)] text-xs max-h-96 overflow-x-auto overflow-y-auto tool-result-content w-full min-w-0" | |
| x-html="formatToolResult(message.content)" | |
| x-effect="if (message.expanded && window.hljs) { setTimeout(() => { $el.querySelectorAll('pre code.language-json').forEach(block => { if (!block.classList.contains('hljs')) window.hljs.highlightElement(block); }); }, 50); }"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- User and Assistant messages --> | |
| <div :class="message.role === 'user' ? 'flex items-start space-x-2 justify-end' : 'flex items-start space-x-2'"> | |
| {{ if .Model }} | |
| {{ $galleryConfig:= index $allGalleryConfigs .Model}} | |
| <template x-if="message.role === 'user'"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="flex flex-col flex-1 items-end"> | |
| <span class="text-xs font-semibold text-[var(--color-text-secondary)] mb-1">You</span> | |
| <div class="p-3 flex-1 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] border border-[var(--color-primary-border)]/20 shadow-lg" x-html="message.html"></div> | |
| <template x-if="message.image && message.image.length > 0"> | |
| <div class="mt-2 space-y-2"> | |
| <template x-for="(img, index) in message.image" :key="index"> | |
| <img :src="img" :alt="'Image ' + (index + 1)" class="rounded-lg max-w-xs"> | |
| </template> | |
| </div> | |
| </template> | |
| <template x-if="message.audio && message.audio.length > 0"> | |
| <div class="mt-2 space-y-2"> | |
| <template x-for="(audio, index) in message.audio" :key="index"> | |
| <audio controls class="w-full"> | |
| <source :src="audio" type="audio/*"> | |
| Your browser does not support the audio element. | |
| </audio> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| <template x-if="message.role != 'user' && message.role != 'thinking' && message.role != 'reasoning' && message.role != 'tool_call' && message.role != 'tool_result'"> | |
| <div class="flex items-center space-x-2"> | |
| <!-- Model icon - from message history, fallback to active chat --> | |
| <template x-if="message.model && window.__galleryConfigs && window.__galleryConfigs[message.model] && window.__galleryConfigs[message.model].Icon"> | |
| <img :src="window.__galleryConfigs[message.model].Icon" class="rounded-lg mt-2 max-w-8 max-h-8 border border-[var(--color-primary-border)]/20"> | |
| </template> | |
| <!-- Fallback: use active chat model if message doesn't have one --> | |
| <template x-if="!message.model && $store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model] && window.__galleryConfigs[$store.chat.activeChat().model].Icon"> | |
| <img :src="window.__galleryConfigs[$store.chat.activeChat().model].Icon" class="rounded-lg mt-2 max-w-8 max-h-8 border border-[var(--color-primary-border)]/20"> | |
| </template> | |
| <!-- Final fallback: initial model from server --> | |
| <template x-if="!message.model && (!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}'] && window.__galleryConfigs['{{$model}}'].Icon"> | |
| <img :src="window.__galleryConfigs['{{$model}}'].Icon" class="rounded-lg mt-2 max-w-8 max-h-8 border border-[var(--color-primary-border)]/20"> | |
| </template> | |
| <div class="flex flex-col flex-1"> | |
| <span class="text-xs font-semibold text-[var(--color-text-secondary)] mb-1" x-text="message.model || $store.chat.activeChat()?.model || '{{if .Model}}{{.Model}}{{else}}Assistant{{end}}'"></span> | |
| <div class="flex-1 text-[var(--color-text-primary)] flex items-center space-x-2 min-w-0"> | |
| <div class="p-3 rounded-lg bg-[var(--color-bg-secondary)] border border-[var(--color-accent-border)]/20 shadow-lg max-w-full overflow-x-auto overflow-wrap-anywhere" x-html="message.html"></div> | |
| <button @click="copyToClipboard(message.html)" title="Copy to clipboard" class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors p-1 flex-shrink-0"> | |
| <i class="fa-solid fa-copy"></i> | |
| </button> | |
| </div> | |
| <template x-if="message.image && message.image.length > 0"> | |
| <div class="mt-2 space-y-2"> | |
| <template x-for="(img, index) in message.image" :key="index"> | |
| <img :src="img" :alt="'Image ' + (index + 1)" class="rounded-lg max-w-xs"> | |
| </template> | |
| </div> | |
| </template> | |
| <template x-if="message.audio && message.audio.length > 0"> | |
| <div class="mt-2 space-y-2"> | |
| <template x-for="(audio, index) in message.audio" :key="index"> | |
| <audio controls class="w-full"> | |
| <source :src="audio" type="audio/*"> | |
| Your browser does not support the audio element. | |
| </audio> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| {{ else }} | |
| <i | |
| class="fa-solid h-8 w-8" | |
| :class="message.role === 'user' ? 'fa-user text-[var(--color-primary)]' : 'fa-robot text-[var(--color-accent)]'" | |
| ></i> | |
| {{ end }} | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| <!-- Chat Input --> | |
| <div class="p-4 border-t border-[var(--color-bg-secondary)]" x-data="{ inputValue: '', shiftPressed: false, attachedFiles: [] }"> | |
| <form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt" class="max-w-3xl mx-auto"> | |
| <!-- Attachment Tags - Show above input when files are attached --> | |
| <div x-show="attachedFiles.length > 0" class="mb-3 flex flex-wrap gap-2 items-center"> | |
| <template x-for="(file, index) in attachedFiles" :key="index"> | |
| <div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-[var(--color-primary)]/20 border border-[var(--color-primary-border)]/40 text-[var(--color-text-primary)]"> | |
| <i :class="file.type === 'image' ? 'fa-solid fa-image' : file.type === 'audio' ? 'fa-solid fa-microphone' : 'fa-solid fa-file'" class="text-[var(--color-primary)]"></i> | |
| <span x-text="file.name" class="max-w-[200px] truncate"></span> | |
| <button | |
| type="button" | |
| @click="attachedFiles.splice(index, 1); removeFileFromInput(file.type, file.name)" | |
| class="ml-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors" | |
| title="Remove attachment" | |
| > | |
| <i class="fa-solid fa-times text-xs"></i> | |
| </button> | |
| </div> | |
| </template> | |
| </div> | |
| <!-- Token Usage and Context Window - Compact above input --> | |
| <div class="mb-3 flex items-center justify-between gap-4 text-xs"> | |
| <!-- Token Usage --> | |
| <div class="flex items-center gap-3 text-[var(--color-text-secondary)]"> | |
| <div class="flex items-center gap-1"> | |
| <i class="fas fa-chart-line text-[var(--color-primary)]"></i> | |
| <span>Prompt:</span> | |
| <span class="text-[var(--color-text-primary)] font-medium" x-text="new Intl.NumberFormat().format($store.chat.activeChat()?.tokenUsage?.promptTokens || 0)"></span> | |
| </div> | |
| <div class="flex items-center gap-1"> | |
| <span>Completion:</span> | |
| <span class="text-[var(--color-text-primary)] font-medium" x-text="new Intl.NumberFormat().format($store.chat.activeChat()?.tokenUsage?.completionTokens || 0)"></span> | |
| </div> | |
| <div class="flex items-center gap-1 border-l border-[var(--color-bg-secondary)] pl-3"> | |
| <span class="text-[var(--color-primary)] font-semibold">Total:</span> | |
| <span class="text-[var(--color-text-primary)] font-bold" x-text="new Intl.NumberFormat().format($store.chat.activeChat()?.tokenUsage?.totalTokens || 0)"></span> | |
| </div> | |
| <!-- Tokens per second display --> | |
| <div id="tokens-per-second-container" class="flex items-center gap-1 border-l border-[var(--color-bg-secondary)] pl-3"> | |
| <i class="fas fa-tachometer-alt text-[var(--color-primary)]"></i> | |
| <span id="tokens-per-second" class="text-[var(--color-text-primary)] font-medium">-</span> | |
| <span id="max-tokens-per-second-badge" class="ml-2 px-1.5 py-0.5 text-[10px] bg-[var(--color-primary)]/20 text-[var(--color-primary)] rounded border border-[var(--color-primary-border)]/30 hidden"></span> | |
| </div> | |
| </div> | |
| <!-- Context Window --> | |
| <template x-if="$store.chat.activeChat()?.contextSize && $store.chat.activeChat().contextSize > 0"> | |
| <div class="flex items-center gap-2 text-[var(--color-text-secondary)]"> | |
| <i class="fas fa-database text-[var(--color-primary)]"></i> | |
| <span> | |
| <span class="text-[var(--color-text-primary)] font-medium" x-text="new Intl.NumberFormat().format($store.chat.activeChat()?.tokenUsage?.totalTokens || 0)"></span> | |
| / | |
| <span class="text-[var(--color-text-primary)] font-medium" x-text="new Intl.NumberFormat().format($store.chat.activeChat()?.contextSize || 0)"></span> | |
| </span> | |
| <div class="w-16 bg-[var(--color-bg-primary)] rounded-full h-1.5 overflow-hidden border border-[var(--color-bg-secondary)]"> | |
| <div class="h-full rounded-full transition-all duration-300 ease-out" | |
| :class="{ | |
| 'bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-accent)]': $store.chat.getContextUsagePercent() < 80, | |
| 'bg-gradient-to-r from-yellow-500 to-orange-500': $store.chat.getContextUsagePercent() >= 80 && $store.chat.getContextUsagePercent() < 95, | |
| 'bg-gradient-to-r from-red-500 to-red-600': $store.chat.getContextUsagePercent() >= 95 | |
| }" | |
| :style="'width: ' + Math.min(100, $store.chat.getContextUsagePercent()) + '%'"> | |
| </div> | |
| </div> | |
| <span class="text-[var(--color-text-secondary)]" x-text="Math.round($store.chat.getContextUsagePercent()) + '%'"></span> | |
| <span x-show="$store.chat.getContextUsagePercent() >= 80" class="text-[var(--color-warning)]"> | |
| <i class="fas fa-exclamation-triangle"></i> | |
| </span> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="relative w-full"> | |
| <textarea | |
| id="input" | |
| name="input" | |
| x-model="inputValue" | |
| class="input w-full p-3 pr-16 resize-none border-0" | |
| placeholder="Send a message..." | |
| class="p-3 pr-16 w-full bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] placeholder-[var(--color-text-secondary)] focus:outline-none resize-none border-0 rounded-xl transition-colors duration-200" | |
| required | |
| @keydown.shift="shiftPressed = true" | |
| @keyup.shift="shiftPressed = false" | |
| @keydown.enter.prevent="if (!shiftPressed) { submitPrompt($event); }" | |
| rows="2" | |
| ></textarea> | |
| <button | |
| type="button" | |
| onclick="document.getElementById('input_image').click()" | |
| class="fa-solid fa-image text-[var(--color-text-secondary)] absolute right-12 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200" | |
| title="Attach images" | |
| ></button> | |
| <button | |
| type="button" | |
| onclick="document.getElementById('input_audio').click()" | |
| class="fa-solid fa-microphone text-[var(--color-text-secondary)] absolute right-20 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200" | |
| title="Attach an audio file" | |
| ></button> | |
| <button | |
| type="button" | |
| onclick="document.getElementById('input_file').click()" | |
| class="fa-solid fa-file text-[var(--color-text-secondary)] absolute right-28 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200" | |
| title="Upload text, markdown or PDF file" | |
| ></button> | |
| <!-- Send button and stop button in the same position --> | |
| <div class="absolute right-3 top-3 flex items-center"> | |
| <!-- Stop button (hidden by default, shown when request is in progress) --> | |
| <button | |
| id="stop-button" | |
| type="button" | |
| onclick="stopRequest()" | |
| class="text-lg p-2 text-[var(--color-error)] hover:text-[var(--color-error)] transition-colors duration-200" | |
| style="display: none;" | |
| title="Stop request" | |
| > | |
| <i class="fa-solid fa-stop"></i> | |
| </button> | |
| <!-- Send button --> | |
| <button | |
| id="send-button" | |
| type="submit" | |
| class="text-lg p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors duration-200" | |
| title="Send message (Enter)" | |
| > | |
| <i class="fa-solid fa-paper-plane"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </form> | |
| <input id="chat-model" type="hidden" value="{{.Model}}" {{ if .ContextSize }}data-context-size="{{.ContextSize}}"{{ end }}> | |
| <input | |
| id="input_image" | |
| type="file" | |
| multiple | |
| accept="image/*" | |
| style="display: none;" | |
| @change="handleFileSelection($event, 'image')" | |
| /> | |
| <input | |
| id="input_audio" | |
| type="file" | |
| multiple | |
| accept="audio/*" | |
| style="display: none;" | |
| @change="handleFileSelection($event, 'audio')" | |
| /> | |
| <input | |
| id="input_file" | |
| type="file" | |
| multiple | |
| accept=".txt,.md,.pdf" | |
| style="display: none;" | |
| @change="handleFileSelection($event, 'file')" | |
| /> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Modal moved outside of sidebar to appear in center of page - Always available, content updated dynamically --> | |
| <div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full md:inset-0 max-h-full" style="padding: 1rem;"> | |
| <div class="relative p-4 w-full max-w-2xl max-h-full"> | |
| <div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700"> | |
| <!-- Header --> | |
| <div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"> | |
| <h3 id="model-info-modal-title" class="text-xl font-semibold text-gray-900 dark:text-white">{{ if $model }}{{ $model }}{{ end }}</h3> | |
| <button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }"> | |
| <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> | |
| <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/> | |
| </svg> | |
| <span class="sr-only">Close modal</span> | |
| </button> | |
| </div> | |
| <!-- Body --> | |
| <div class="p-4 md:p-5 space-y-4"> | |
| <div class="flex justify-center items-center"> | |
| <img id="model-info-modal-icon" class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" style="display: none;" loading="lazy"/> | |
| </div> | |
| <div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full"></div> | |
| <hr> | |
| <p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p> | |
| <ul id="model-info-links"> | |
| </ul> | |
| </div> | |
| <!-- Footer --> | |
| <div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600"> | |
| <button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }"> | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Alpine store initialization and utilities --> | |
| <script> | |
| document.addEventListener("alpine:init", () => { | |
| window.copyToClipboard = (content) => { | |
| const tempElement = document.createElement('div'); | |
| tempElement.innerHTML = content; | |
| const text = tempElement.textContent || tempElement.innerText; | |
| navigator.clipboard.writeText(text).then(() => { | |
| alert('Copied to clipboard!'); | |
| }).catch(err => { | |
| console.error('Failed to copy: ', err); | |
| }); | |
| }; | |
| // Format tool result for better display | |
| window.formatToolResult = (content) => { | |
| if (!content) return ''; | |
| try { | |
| // Try to parse as JSON | |
| const parsed = JSON.parse(content); | |
| // If it has a 'result' field, try to parse that too | |
| if (parsed.result && typeof parsed.result === 'string') { | |
| try { | |
| const resultParsed = JSON.parse(parsed.result); | |
| parsed.result = resultParsed; | |
| } catch (e) { | |
| // Keep as string if not JSON | |
| } | |
| } | |
| // Format the JSON nicely | |
| const formatted = JSON.stringify(parsed, null, 2); | |
| return DOMPurify.sanitize('<pre class="bg-[var(--color-bg-primary)] p-3 rounded border border-[var(--color-success)]/20 overflow-x-auto"><code class="language-json">' + formatted + '</code></pre>'); | |
| } catch (e) { | |
| // If not JSON, try to format as markdown or plain text | |
| try { | |
| // Check if it's a markdown code block | |
| if (content.includes('```')) { | |
| return DOMPurify.sanitize(marked.parse(content)); | |
| } | |
| // Otherwise, try to parse as JSON one more time with error handling | |
| const lines = content.split('\n'); | |
| let jsonStart = -1; | |
| let jsonEnd = -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (lines[i].trim().startsWith('{') || lines[i].trim().startsWith('[')) { | |
| jsonStart = i; | |
| break; | |
| } | |
| } | |
| if (jsonStart >= 0) { | |
| for (let i = lines.length - 1; i >= jsonStart; i--) { | |
| if (lines[i].trim().endsWith('}') || lines[i].trim().endsWith(']')) { | |
| jsonEnd = i; | |
| break; | |
| } | |
| } | |
| if (jsonEnd >= jsonStart) { | |
| const jsonStr = lines.slice(jsonStart, jsonEnd + 1).join('\n'); | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| const formatted = JSON.stringify(parsed, null, 2); | |
| return DOMPurify.sanitize('<pre class="bg-[var(--color-bg-primary)] p-3 rounded border border-[var(--color-success)]/20 overflow-x-auto"><code class="language-json">' + formatted + '</code></pre>'); | |
| } catch (e2) { | |
| // Fall through to markdown | |
| } | |
| } | |
| } | |
| // Fall back to markdown | |
| return DOMPurify.sanitize(marked.parse(content)); | |
| } catch (e2) { | |
| // Last resort: plain text | |
| return DOMPurify.sanitize('<pre class="bg-[var(--color-bg-primary)] p-3 rounded border border-[var(--color-success)]/20 overflow-x-auto text-xs">' + content.replace(/</g, '<').replace(/>/g, '>') + '</pre>'); | |
| } | |
| } | |
| }; | |
| // Get tool name from content | |
| window.getToolName = (content) => { | |
| if (!content || typeof content !== 'string') return ''; | |
| try { | |
| const parsed = JSON.parse(content); | |
| return parsed.name || ''; | |
| } catch (e) { | |
| // Try to extract name from string | |
| const nameMatch = content.match(/"name"\s*:\s*"([^"]+)"/); | |
| return nameMatch ? nameMatch[1] : ''; | |
| } | |
| }; | |
| // Chat management functions are defined in chat.js and available globally | |
| // These are just placeholders - the actual implementations are in chat.js | |
| // Get last message preview for chat list | |
| window.getLastMessagePreview = (chat) => { | |
| if (!chat || !chat.history || chat.history.length === 0) { | |
| return 'No messages yet'; | |
| } | |
| const lastMessage = chat.history[chat.history.length - 1]; | |
| if (!lastMessage || !lastMessage.content) { | |
| return 'No messages yet'; | |
| } | |
| // Get plain text from content (remove HTML tags) | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = lastMessage.content; | |
| const text = tempDiv.textContent || tempDiv.innerText || ''; | |
| return text.substring(0, 40) + (text.length > 40 ? '...' : ''); | |
| }; | |
| // Format chat date for display | |
| window.formatChatDate = (timestamp) => { | |
| if (!timestamp) return ''; | |
| const date = new Date(timestamp); | |
| const now = new Date(); | |
| const diffMs = now - date; | |
| const diffMins = Math.floor(diffMs / 60000); | |
| const diffHours = Math.floor(diffMs / 3600000); | |
| const diffDays = Math.floor(diffMs / 86400000); | |
| if (diffMins < 1) { | |
| return 'Just now'; | |
| } else if (diffMins < 60) { | |
| return `${diffMins}m ago`; | |
| } else if (diffHours < 24) { | |
| return `${diffHours}h ago`; | |
| } else if (diffDays < 7) { | |
| return `${diffDays}d ago`; | |
| } else { | |
| // Show date for older chats | |
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| } | |
| }; | |
| }); | |
| // Context size is now initialized in the Alpine store initialization above | |
| // Process markdown in model info modal when it opens | |
| function initMarkdownProcessing() { | |
| // Wait for marked and DOMPurify to be available | |
| if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { | |
| setTimeout(initMarkdownProcessing, 100); | |
| return; | |
| } | |
| const modalElement = document.getElementById('model-info-modal'); | |
| const descriptionElement = document.getElementById('model-info-description'); | |
| if (!modalElement || !descriptionElement) { | |
| return; | |
| } | |
| // Store original text in data attribute if not already stored | |
| let originalText = descriptionElement.dataset.originalText; | |
| if (!originalText) { | |
| originalText = descriptionElement.textContent || descriptionElement.innerText; | |
| descriptionElement.dataset.originalText = originalText; | |
| } | |
| // Process markdown function | |
| const processMarkdown = () => { | |
| if (!descriptionElement || !originalText) return; | |
| try { | |
| // Check if already processed (has HTML tags that look like markdown output) | |
| const currentContent = descriptionElement.innerHTML.trim(); | |
| if (currentContent.startsWith('<') && (currentContent.includes('<p>') || currentContent.includes('<h') || currentContent.includes('<ul>') || currentContent.includes('<ol>'))) { | |
| return; // Already processed | |
| } | |
| // Use stored original text | |
| const textToProcess = descriptionElement.dataset.originalText || originalText; | |
| if (textToProcess && textToProcess.trim()) { | |
| const html = marked.parse(textToProcess); | |
| descriptionElement.innerHTML = DOMPurify.sanitize(html); | |
| } | |
| } catch (error) { | |
| console.error('Error rendering markdown:', error); | |
| } | |
| }; | |
| // Process immediately if modal is already visible | |
| if (!modalElement.classList.contains('hidden')) { | |
| processMarkdown(); | |
| } | |
| // Listen for modal show events - check both aria-hidden and class changes | |
| const observer = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| if (mutation.type === 'attributes') { | |
| const isHidden = modalElement.classList.contains('hidden') || | |
| modalElement.getAttribute('aria-hidden') === 'true'; | |
| if (!isHidden) { | |
| // Modal is now visible, process markdown | |
| setTimeout(processMarkdown, 150); | |
| } | |
| } | |
| }); | |
| }); | |
| observer.observe(modalElement, { | |
| attributes: true, | |
| attributeFilter: ['aria-hidden', 'class'], | |
| childList: false, | |
| subtree: false | |
| }); | |
| // Also listen for click events on modal toggle buttons | |
| // Use event delegation to handle dynamically created buttons | |
| document.addEventListener('click', (e) => { | |
| const button = e.target.closest('[data-modal-toggle="model-info-modal"]'); | |
| if (button) { | |
| // Update modal with current model before showing | |
| if (window.Alpine && window.Alpine.store("chat")) { | |
| const activeChat = window.Alpine.store("chat").activeChat(); | |
| const modelName = activeChat ? activeChat.model : (button.dataset.modelName || (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null)); | |
| if (modelName && window.updateModelInfoModal) { | |
| window.updateModelInfoModal(modelName, true); | |
| } | |
| } | |
| setTimeout(processMarkdown, 300); | |
| } | |
| }); | |
| // Process on initial load if libraries are ready | |
| setTimeout(processMarkdown, 200); | |
| } | |
| // Start initialization | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initMarkdownProcessing); | |
| } else { | |
| initMarkdownProcessing(); | |
| } | |
| // Sync model selector with initial model from server on page load | |
| // This ensures the selector is correct when navigating from manage.html or index.html | |
| function syncModelSelectorOnLoad() { | |
| const modelInput = document.getElementById("chat-model"); | |
| const modelSelector = document.getElementById("modelSelector"); | |
| if (modelInput && modelSelector && modelInput.value) { | |
| const modelName = modelInput.value; | |
| const optionValue = 'chat/' + modelName; | |
| // Find and select the option matching the model | |
| for (let i = 0; i < modelSelector.options.length; i++) { | |
| if (modelSelector.options[i].value === optionValue) { | |
| modelSelector.selectedIndex = i; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // Run sync after DOM is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', syncModelSelectorOnLoad); | |
| } else { | |
| syncModelSelectorOnLoad(); | |
| } | |
| // Function to update model info modal with current model | |
| // Set openModal to true to actually open the modal, false to just update content | |
| window.updateModelInfoModal = function(modelName, openModal = false) { | |
| if (!modelName) { | |
| return; | |
| } | |
| if (!window.__galleryConfigs) { | |
| return; | |
| } | |
| const galleryConfig = window.__galleryConfigs[modelName]; | |
| // Check if galleryConfig exists and has at least one property | |
| if (!galleryConfig || Object.keys(galleryConfig).length === 0) { | |
| // Still update the modal title even if no config, so user can see which model they clicked | |
| const titleEl = document.getElementById('model-info-modal-title'); | |
| if (titleEl) { | |
| titleEl.textContent = modelName; | |
| } | |
| // Show message that no info is available | |
| const descEl = document.getElementById('model-info-description'); | |
| if (descEl) { | |
| descEl.textContent = 'No additional information available for this model.'; | |
| } | |
| const linksEl = document.getElementById('model-info-links'); | |
| if (linksEl) { | |
| linksEl.innerHTML = ''; | |
| } | |
| const iconEl = document.getElementById('model-info-modal-icon'); | |
| if (iconEl) { | |
| iconEl.style.display = 'none'; | |
| } | |
| // Only open the modal if explicitly requested | |
| if (openModal) { | |
| const modalElement = document.getElementById('model-info-modal'); | |
| if (modalElement) { | |
| modalElement.classList.remove('hidden'); | |
| modalElement.setAttribute('aria-hidden', 'false'); | |
| // Add backdrop | |
| let backdrop = document.querySelector('.modal-backdrop'); | |
| if (!backdrop) { | |
| backdrop = document.createElement('div'); | |
| backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40'; | |
| document.body.appendChild(backdrop); | |
| backdrop.addEventListener('click', () => { | |
| closeModelInfoModal(); | |
| }); | |
| } | |
| } | |
| } | |
| return; | |
| } | |
| // Update modal title | |
| const titleEl = document.getElementById('model-info-modal-title'); | |
| if (titleEl) { | |
| titleEl.textContent = modelName; | |
| } | |
| // Update icon | |
| const iconEl = document.getElementById('model-info-modal-icon'); | |
| if (iconEl) { | |
| if (galleryConfig.Icon) { | |
| iconEl.src = galleryConfig.Icon; | |
| iconEl.style.display = 'block'; | |
| } else { | |
| iconEl.style.display = 'none'; | |
| } | |
| } | |
| // Update description | |
| const descEl = document.getElementById('model-info-description'); | |
| if (descEl) { | |
| descEl.textContent = galleryConfig.Description || 'No description available.'; | |
| } | |
| // Update links | |
| const linksEl = document.getElementById('model-info-links'); | |
| if (linksEl && galleryConfig.URLs && Array.isArray(galleryConfig.URLs) && galleryConfig.URLs.length > 0) { | |
| linksEl.innerHTML = ''; | |
| galleryConfig.URLs.forEach(url => { | |
| const li = document.createElement('li'); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.target = '_blank'; | |
| a.textContent = url; | |
| li.appendChild(a); | |
| linksEl.appendChild(li); | |
| }); | |
| } else if (linksEl) { | |
| linksEl.innerHTML = '<li>No links available</li>'; | |
| } | |
| // Only open the modal if explicitly requested | |
| if (openModal) { | |
| const modalElement = document.getElementById('model-info-modal'); | |
| if (modalElement) { | |
| // Ensure positioning classes are present (they might have been removed) | |
| if (!modalElement.classList.contains('flex')) { | |
| modalElement.classList.add('flex'); | |
| } | |
| if (!modalElement.classList.contains('justify-center')) { | |
| modalElement.classList.add('justify-center'); | |
| } | |
| if (!modalElement.classList.contains('items-center')) { | |
| modalElement.classList.add('items-center'); | |
| } | |
| // Ensure fixed positioning | |
| if (!modalElement.classList.contains('fixed')) { | |
| modalElement.classList.add('fixed'); | |
| } | |
| // Ensure full width and height | |
| if (!modalElement.classList.contains('w-full')) { | |
| modalElement.classList.add('w-full'); | |
| } | |
| if (!modalElement.classList.contains('h-full')) { | |
| modalElement.classList.add('h-full'); | |
| } | |
| // Ensure padding is set | |
| if (!modalElement.style.padding) { | |
| modalElement.style.padding = '1rem'; | |
| } | |
| // Remove hidden class if present | |
| modalElement.classList.remove('hidden'); | |
| // Set aria-hidden to false | |
| modalElement.setAttribute('aria-hidden', 'false'); | |
| // Add backdrop if needed | |
| let backdrop = document.querySelector('.modal-backdrop'); | |
| if (!backdrop) { | |
| backdrop = document.createElement('div'); | |
| backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40'; | |
| document.body.appendChild(backdrop); | |
| backdrop.addEventListener('click', () => { | |
| window.closeModelInfoModal(); | |
| }); | |
| } | |
| } | |
| } | |
| }; | |
| // Function to close the model info modal | |
| window.closeModelInfoModal = function() { | |
| const modalElement = document.getElementById('model-info-modal'); | |
| if (modalElement) { | |
| modalElement.classList.add('hidden'); | |
| modalElement.setAttribute('aria-hidden', 'true'); | |
| } | |
| const backdrop = document.querySelector('.modal-backdrop'); | |
| if (backdrop) { | |
| backdrop.remove(); | |
| } | |
| }; | |
| // Also sync after Alpine initializes (in case it runs after DOMContentLoaded) | |
| function initializeModelInfo() { | |
| syncModelSelectorOnLoad(); | |
| // Initialize model info modal content with current model (but don't open it) | |
| if (window.updateModelInfoModal && window.Alpine && window.Alpine.store("chat")) { | |
| const activeChat = window.Alpine.store("chat").activeChat(); | |
| const modelName = activeChat ? activeChat.model : (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null); | |
| if (modelName) { | |
| window.updateModelInfoModal(modelName, false); // false = don't open, just update content | |
| } | |
| } | |
| } | |
| if (window.Alpine) { | |
| Alpine.nextTick(initializeModelInfo); | |
| } else { | |
| document.addEventListener('alpine:init', () => { | |
| Alpine.nextTick(initializeModelInfo); | |
| }); | |
| } | |
| </script> | |
| <style> | |
| /* Markdown content overflow handling */ | |
| #model-info-description { | |
| word-wrap: break-word; | |
| overflow-wrap: anywhere; | |
| max-width: 100%; | |
| } | |
| #model-info-description pre { | |
| overflow-x: auto; | |
| max-width: 100%; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| #model-info-description code { | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| #model-info-description pre code { | |
| white-space: pre; | |
| overflow-x: auto; | |
| display: block; | |
| } | |
| #model-info-description table { | |
| max-width: 100%; | |
| overflow-x: auto; | |
| display: block; | |
| } | |
| #model-info-description img { | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| /* Prevent JSON overflow in tool calls and results */ | |
| .tool-call-content, | |
| .tool-result-content { | |
| max-width: 100%; | |
| width: 100%; | |
| overflow-x: auto; | |
| overflow-y: auto; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| box-sizing: border-box; | |
| } | |
| .tool-call-content pre, | |
| .tool-result-content pre { | |
| overflow-x: auto; | |
| overflow-y: auto; | |
| max-width: 100%; | |
| width: 100%; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| white-space: pre; | |
| background: #101827 ; | |
| border: 1px solid #1E293B; | |
| border-radius: 6px; | |
| padding: 12px; | |
| margin: 0; | |
| box-sizing: border-box; | |
| } | |
| .tool-call-content code, | |
| .tool-result-content code { | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| white-space: pre; | |
| background: transparent ; | |
| color: #E5E7EB; | |
| font-family: 'ui-monospace', 'Monaco', 'Consolas', monospace; | |
| font-size: 0.875rem; | |
| line-height: 1.5; | |
| display: block; | |
| max-width: 100%; | |
| width: 100%; | |
| overflow-x: auto; | |
| box-sizing: border-box; | |
| } | |
| /* Ensure parent containers don't overflow */ | |
| .tool-call-content > *, | |
| .tool-result-content > * { | |
| max-width: 100%; | |
| box-sizing: border-box; | |
| } | |
| /* Prevent overflow in assistant messages with code/markdown */ | |
| div[class*="rounded-lg"][class*="bg-gradient"] { | |
| max-width: 100%; | |
| overflow-x: auto; | |
| overflow-wrap: break-word; | |
| word-wrap: break-word; | |
| } | |
| div[class*="rounded-lg"][class*="bg-gradient"] pre, | |
| div[class*="rounded-lg"][class*="bg-gradient"] code { | |
| max-width: 100%; | |
| overflow-x: auto; | |
| overflow-wrap: break-word; | |
| word-wrap: break-word; | |
| white-space: pre; | |
| } | |
| /* Ensure code blocks in assistant messages don't overflow */ | |
| #messages pre, | |
| #messages code { | |
| max-width: 100%; | |
| overflow-x: auto; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| #messages pre code { | |
| white-space: pre; | |
| display: block; | |
| } | |
| /* Dark theme syntax highlighting for JSON */ | |
| .tool-call-content .hljs, | |
| .tool-result-content .hljs { | |
| background: #101827 ; | |
| color: #E5E7EB ; | |
| } | |
| .tool-call-content .hljs-keyword, | |
| .tool-result-content .hljs-keyword { | |
| color: var(--color-accent) ; | |
| font-weight: 600; | |
| } | |
| .tool-call-content .hljs-string, | |
| .tool-result-content .hljs-string { | |
| color: var(--color-success) ; | |
| } | |
| .tool-call-content .hljs-number, | |
| .tool-result-content .hljs-number { | |
| color: var(--color-primary) ; | |
| } | |
| .tool-call-content .hljs-literal, | |
| .tool-result-content .hljs-literal { | |
| color: #F59E0B ; | |
| } | |
| .tool-call-content .hljs-punctuation, | |
| .tool-result-content .hljs-punctuation { | |
| color: #94A3B8 ; | |
| } | |
| .tool-call-content .hljs-property, | |
| .tool-result-content .hljs-property { | |
| color: var(--color-primary) ; | |
| } | |
| .tool-call-content .hljs-attr, | |
| .tool-result-content .hljs-attr { | |
| color: var(--color-accent) ; | |
| } | |
| </style> | |
| <!-- Custom Scrollbar Styling --> | |
| <style> | |
| /* Webkit browsers (Chrome, Safari, Edge) - Minimal and elegant */ | |
| .sidebar::-webkit-scrollbar, | |
| #chat::-webkit-scrollbar, | |
| #messages::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| .sidebar::-webkit-scrollbar-track, | |
| #chat::-webkit-scrollbar-track, | |
| #messages::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .sidebar::-webkit-scrollbar-thumb, | |
| #chat::-webkit-scrollbar-thumb, | |
| #messages::-webkit-scrollbar-thumb { | |
| background: rgba(148, 163, 184, 0.2); | |
| border-radius: 3px; | |
| transition: background 0.2s ease; | |
| } | |
| .sidebar::-webkit-scrollbar-thumb:hover, | |
| #chat::-webkit-scrollbar-thumb:hover, | |
| #messages::-webkit-scrollbar-thumb:hover { | |
| background: rgba(148, 163, 184, 0.4); | |
| } | |
| /* Firefox - Minimal */ | |
| .sidebar, | |
| #chat, | |
| #messages { | |
| scrollbar-width: thin; | |
| scrollbar-color: rgba(148, 163, 184, 0.2) transparent; | |
| } | |
| /* Chat list scrollbar - Even more minimal */ | |
| .max-h-80::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .max-h-80::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .max-h-80::-webkit-scrollbar-thumb { | |
| background: rgba(148, 163, 184, 0.15); | |
| border-radius: 2px; | |
| transition: background 0.2s ease; | |
| } | |
| .max-h-80::-webkit-scrollbar-thumb:hover { | |
| background: rgba(148, 163, 184, 0.3); | |
| } | |
| .max-h-80 { | |
| scrollbar-width: thin; | |
| scrollbar-color: rgba(148, 163, 184, 0.15) transparent; | |
| } | |
| </style> | |
| </body> | |
| </html> | |