| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| <!doctype html> |
| <html lang="en"> |
| {{template "views/partials/head" .}} |
| <script src="static/assets/pdf.min.js"></script> |
| <script> |
| |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'static/assets/pdf.worker.min.js'; |
| </script> |
| <script> |
| |
| |
| var __chatContextSize = null; |
| {{ if .ContextSize }} |
| __chatContextSize = {{ .ContextSize }}; |
| {{ end }} |
| |
| |
| 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 __initChatStore() { |
| if (!window.Alpine) return; |
| |
| |
| |
| let initialMcpMode = false; |
| |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| if (urlParams.get('mcp') === 'true') { |
| initialMcpMode = true; |
| } |
| |
| |
| 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")) { |
| |
| const activeChat = Alpine.store("chat").activeChat(); |
| if (activeChat && __chatContextSize !== null) { |
| activeChat.contextSize = __chatContextSize; |
| } |
| return; |
| } |
| |
| |
| function generateChatId() { |
| return "chat_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9); |
| } |
| |
| |
| function getCurrentModel() { |
| const modelInput = document.getElementById("chat-model"); |
| return modelInput ? modelInput.value : ""; |
| } |
| |
| Alpine.store("chat", { |
| chats: [], |
| activeChatId: null, |
| chatIdCounter: 0, |
| languages: [undefined], |
| activeRequestIds: [], |
| |
| |
| activeChat() { |
| if (!this.activeChatId) return null; |
| return this.chats.find(c => c.id === this.activeChatId) || null; |
| }, |
| |
| |
| getChat(chatId) { |
| return this.chats.find(c => c.id === chatId) || null; |
| }, |
| |
| |
| 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, |
| topP: null, |
| topK: null, |
| tokenUsage: { |
| promptTokens: 0, |
| completionTokens: 0, |
| totalTokens: 0, |
| currentRequest: null |
| }, |
| contextSize: __chatContextSize, |
| createdAt: now, |
| updatedAt: now |
| }; |
| this.chats.push(chat); |
| this.activeChatId = chatId; |
| return chat; |
| }, |
| |
| |
| switchChat(chatId) { |
| if (this.chats.find(c => c.id === chatId)) { |
| this.activeChatId = chatId; |
| |
| const chat = this.activeChat(); |
| if (chat && __chatContextSize !== null) { |
| chat.contextSize = __chatContextSize; |
| } |
| return true; |
| } |
| return false; |
| }, |
| |
| |
| deleteChat(chatId) { |
| const index = this.chats.findIndex(c => c.id === chatId); |
| if (index === -1) return false; |
| |
| this.chats.splice(index, 1); |
| |
| |
| if (this.activeChatId === chatId) { |
| if (this.chats.length > 0) { |
| this.activeChatId = this.chats[0].id; |
| } else { |
| |
| this.createChat(); |
| } |
| } |
| return true; |
| }, |
| |
| |
| 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) { |
| |
| |
| const chat = targetChatId ? this.getChat(targetChatId) : this.activeChat(); |
| if (!chat) return; |
| |
| |
| |
| if (usage) { |
| const currentRequest = chat.tokenUsage.currentRequest || { |
| promptTokens: 0, |
| completionTokens: 0, |
| totalTokens: 0 |
| }; |
| |
| |
| 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) { |
| |
| 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); |
| |
| |
| 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); |
| }, |
| |
| |
| hasActiveRequest(chatId) { |
| if (!chatId) return false; |
| |
| return this.activeRequestIds.includes(chatId); |
| }, |
| |
| |
| 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) { |
| |
| |
| const chat = targetChatId ? this.getChat(targetChatId) : this.activeChat(); |
| if (!chat) return; |
| |
| |
| |
| |
| |
| let messageModel = model; |
| if (!messageModel) { |
| if (role === "user") { |
| |
| messageModel = chat.model || ""; |
| } else if (role === "assistant") { |
| |
| messageModel = chat.model || ""; |
| } else { |
| |
| const lastAssistant = chat.history.slice().reverse().find(m => m.role === "assistant"); |
| messageModel = lastAssistant?.model || chat.model || ""; |
| } |
| } |
| |
| const N = chat.history.length - 1; |
| |
| if (role === "thinking" || role === "reasoning" || role === "tool_call" || role === "tool_result") { |
| let c = ""; |
| if (role === "tool_call" || role === "tool_result") { |
| |
| try { |
| const parsed = typeof content === 'string' ? JSON.parse(content) : content; |
| |
| const formatted = JSON.stringify(parsed, null, 2); |
| c = DOMPurify.sanitize('<pre><code class="language-json">' + formatted + '</code></pre>'); |
| } catch (e) { |
| |
| const lines = content.split("\n"); |
| lines.forEach((line) => { |
| c += DOMPurify.sanitize(marked.parse(line)); |
| }); |
| } |
| } else { |
| |
| const lines = content.split("\n"); |
| lines.forEach((line) => { |
| c += DOMPurify.sanitize(marked.parse(line)); |
| }); |
| } |
| |
| |
| 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 }); |
| |
| |
| 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; |
| } |
| } |
| |
| 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) |
| ); |
| |
| 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]; |
| } |
| |
| 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 |
| }); |
| |
| |
| 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(); |
| |
| |
| if (typeof autoSaveChats === 'function') { |
| autoSaveChats(); |
| } |
| |
| |
| setTimeout(() => { |
| const chatContainer = document.getElementById('chat'); |
| if (chatContainer) { |
| chatContainer.scrollTo({ |
| top: chatContainer.scrollHeight, |
| behavior: 'smooth' |
| }); |
| } |
| |
| 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 = () => { |
| |
| 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); |
| }); |
| |
| 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, |
| })); |
| }, |
| |
| |
| get activeHistory() { |
| const chat = this.activeChat(); |
| return chat ? chat.history : []; |
| }, |
| }); |
| } |
| |
| |
| document.addEventListener("alpine:init", __initChatStore); |
| |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', function() { |
| if (window.Alpine) __initChatStore(); |
| }); |
| } else { |
| |
| if (window.Alpine) __initChatStore(); |
| } |
| |
| |
| window.updateModelAndContextSize = function(selectElement) { |
| if (!window.Alpine || !Alpine.store("chat")) { |
| |
| window.location = selectElement.value; |
| return; |
| } |
| |
| const chatStore = Alpine.store("chat"); |
| const activeChat = chatStore.activeChat(); |
| |
| if (!activeChat) { |
| window.location = selectElement.value; |
| return; |
| } |
| |
| |
| const selectedOption = selectElement.options[selectElement.selectedIndex]; |
| const modelName = selectElement.value.replace('chat/', ''); |
| |
| |
| activeChat.model = modelName; |
| activeChat.updatedAt = Date.now(); |
| |
| |
| if (window.updateModelInfoModal) { |
| window.updateModelInfoModal(modelName); |
| } |
| |
| |
| let contextSize = null; |
| if (selectedOption.dataset.contextSize) { |
| contextSize = parseInt(selectedOption.dataset.contextSize); |
| if (!isNaN(contextSize)) { |
| activeChat.contextSize = contextSize; |
| } else { |
| activeChat.contextSize = null; |
| } |
| } else { |
| |
| activeChat.contextSize = null; |
| } |
| |
| |
| const hasMCP = selectedOption.getAttribute('data-has-mcp') === 'true'; |
| if (!hasMCP) { |
| |
| activeChat.mcpMode = false; |
| } |
| |
| |
| |
| 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'); |
| } |
| } |
| |
| |
| |
| |
| const modelSelector = document.getElementById('modelSelector'); |
| if (modelSelector) { |
| |
| const optionValue = 'chat/' + modelName; |
| for (let i = 0; i < modelSelector.options.length; i++) { |
| if (modelSelector.options[i].value === optionValue) { |
| |
| if (modelSelector.selectedIndex !== i) { |
| modelSelector.selectedIndex = i; |
| } |
| break; |
| } |
| } |
| |
| |
| } |
| |
| |
| |
| |
| |
| if (typeof autoSaveChats === 'function') { |
| autoSaveChats(); |
| } |
| |
| |
| 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" .}} |
|
|
| |
| <div class="flex flex-1 overflow-hidden relative"> |
| |
| <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> |
|
|
| |
| <div class="p-3 space-y-3"> |
| |
| <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"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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"> |
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div |
| class="flex-1 flex flex-col transition-all duration-300 ease-in-out" |
| :class="sidebarOpen ? 'ml-56' : 'ml-0'"> |
|
|
| |
| <div class="border-b border-[var(--color-bg-secondary)] p-4 flex items-center justify-between"> |
| <div class="flex items-center"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| |
| |
| <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> |
|
|
| |
| <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> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <div class="mb-3 flex items-center justify-between gap-4 text-xs"> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="absolute right-3 top-3 flex items-center"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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); |
| }); |
| }; |
| |
| |
| window.formatToolResult = (content) => { |
| if (!content) return ''; |
| try { |
| |
| const parsed = JSON.parse(content); |
| |
| |
| if (parsed.result && typeof parsed.result === 'string') { |
| try { |
| const resultParsed = JSON.parse(parsed.result); |
| parsed.result = resultParsed; |
| } catch (e) { |
| |
| } |
| } |
| |
| |
| 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) { |
| |
| try { |
| |
| if (content.includes('```')) { |
| return DOMPurify.sanitize(marked.parse(content)); |
| } |
| |
| 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) { |
| |
| } |
| } |
| } |
| |
| return DOMPurify.sanitize(marked.parse(content)); |
| } catch (e2) { |
| |
| 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>'); |
| } |
| } |
| }; |
| |
| |
| window.getToolName = (content) => { |
| if (!content || typeof content !== 'string') return ''; |
| try { |
| const parsed = JSON.parse(content); |
| return parsed.name || ''; |
| } catch (e) { |
| |
| const nameMatch = content.match(/"name"\s*:\s*"([^"]+)"/); |
| return nameMatch ? nameMatch[1] : ''; |
| } |
| }; |
| |
| |
| |
| |
| |
| 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'; |
| } |
| |
| const tempDiv = document.createElement('div'); |
| tempDiv.innerHTML = lastMessage.content; |
| const text = tempDiv.textContent || tempDiv.innerText || ''; |
| return text.substring(0, 40) + (text.length > 40 ? '...' : ''); |
| }; |
| |
| |
| 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 { |
| |
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); |
| } |
| }; |
| }); |
| |
| |
| |
| |
| function initMarkdownProcessing() { |
| |
| 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; |
| } |
| |
| |
| let originalText = descriptionElement.dataset.originalText; |
| if (!originalText) { |
| originalText = descriptionElement.textContent || descriptionElement.innerText; |
| descriptionElement.dataset.originalText = originalText; |
| } |
| |
| |
| const processMarkdown = () => { |
| if (!descriptionElement || !originalText) return; |
| |
| try { |
| |
| const currentContent = descriptionElement.innerHTML.trim(); |
| if (currentContent.startsWith('<') && (currentContent.includes('<p>') || currentContent.includes('<h') || currentContent.includes('<ul>') || currentContent.includes('<ol>'))) { |
| return; |
| } |
| |
| |
| 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); |
| } |
| }; |
| |
| |
| if (!modalElement.classList.contains('hidden')) { |
| processMarkdown(); |
| } |
| |
| |
| 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) { |
| |
| setTimeout(processMarkdown, 150); |
| } |
| } |
| }); |
| }); |
| |
| observer.observe(modalElement, { |
| attributes: true, |
| attributeFilter: ['aria-hidden', 'class'], |
| childList: false, |
| subtree: false |
| }); |
| |
| |
| |
| document.addEventListener('click', (e) => { |
| const button = e.target.closest('[data-modal-toggle="model-info-modal"]'); |
| if (button) { |
| |
| 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); |
| } |
| }); |
| |
| |
| setTimeout(processMarkdown, 200); |
| } |
| |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', initMarkdownProcessing); |
| } else { |
| initMarkdownProcessing(); |
| } |
| |
| |
| |
| 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; |
| |
| |
| for (let i = 0; i < modelSelector.options.length; i++) { |
| if (modelSelector.options[i].value === optionValue) { |
| modelSelector.selectedIndex = i; |
| break; |
| } |
| } |
| } |
| } |
| |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', syncModelSelectorOnLoad); |
| } else { |
| syncModelSelectorOnLoad(); |
| } |
| |
| |
| |
| window.updateModelInfoModal = function(modelName, openModal = false) { |
| if (!modelName) { |
| return; |
| } |
| if (!window.__galleryConfigs) { |
| return; |
| } |
| |
| const galleryConfig = window.__galleryConfigs[modelName]; |
| |
| if (!galleryConfig || Object.keys(galleryConfig).length === 0) { |
| |
| const titleEl = document.getElementById('model-info-modal-title'); |
| if (titleEl) { |
| titleEl.textContent = modelName; |
| } |
| |
| 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'; |
| } |
| |
| if (openModal) { |
| const modalElement = document.getElementById('model-info-modal'); |
| if (modalElement) { |
| modalElement.classList.remove('hidden'); |
| modalElement.setAttribute('aria-hidden', 'false'); |
| |
| 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; |
| } |
| |
| |
| const titleEl = document.getElementById('model-info-modal-title'); |
| if (titleEl) { |
| titleEl.textContent = modelName; |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| const descEl = document.getElementById('model-info-description'); |
| if (descEl) { |
| descEl.textContent = galleryConfig.Description || 'No description available.'; |
| } |
| |
| |
| 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>'; |
| } |
| |
| |
| if (openModal) { |
| const modalElement = document.getElementById('model-info-modal'); |
| if (modalElement) { |
| |
| 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'); |
| } |
| |
| if (!modalElement.classList.contains('fixed')) { |
| modalElement.classList.add('fixed'); |
| } |
| |
| if (!modalElement.classList.contains('w-full')) { |
| modalElement.classList.add('w-full'); |
| } |
| if (!modalElement.classList.contains('h-full')) { |
| modalElement.classList.add('h-full'); |
| } |
| |
| if (!modalElement.style.padding) { |
| modalElement.style.padding = '1rem'; |
| } |
| |
| modalElement.classList.remove('hidden'); |
| |
| modalElement.setAttribute('aria-hidden', 'false'); |
| |
| 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(); |
| }); |
| } |
| } |
| } |
| }; |
| |
| |
| 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(); |
| } |
| }; |
| |
| |
| function initializeModelInfo() { |
| syncModelSelectorOnLoad(); |
| |
| 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); |
| } |
| } |
| } |
| |
| if (window.Alpine) { |
| Alpine.nextTick(initializeModelInfo); |
| } else { |
| document.addEventListener('alpine:init', () => { |
| Alpine.nextTick(initializeModelInfo); |
| }); |
| } |
| </script> |
|
|
| <style> |
| |
| #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; |
| } |
| |
| |
| .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 !important; |
| 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 !important; |
| 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; |
| } |
| |
| |
| .tool-call-content > *, |
| .tool-result-content > * { |
| max-width: 100%; |
| box-sizing: border-box; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| #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; |
| } |
| |
| |
| .tool-call-content .hljs, |
| .tool-result-content .hljs { |
| background: #101827 !important; |
| color: #E5E7EB !important; |
| } |
| |
| .tool-call-content .hljs-keyword, |
| .tool-result-content .hljs-keyword { |
| color: var(--color-accent) !important; |
| font-weight: 600; |
| } |
| |
| .tool-call-content .hljs-string, |
| .tool-result-content .hljs-string { |
| color: var(--color-success) !important; |
| } |
| |
| .tool-call-content .hljs-number, |
| .tool-result-content .hljs-number { |
| color: var(--color-primary) !important; |
| } |
| |
| .tool-call-content .hljs-literal, |
| .tool-result-content .hljs-literal { |
| color: #F59E0B !important; |
| } |
| |
| .tool-call-content .hljs-punctuation, |
| .tool-result-content .hljs-punctuation { |
| color: #94A3B8 !important; |
| } |
| |
| .tool-call-content .hljs-property, |
| .tool-result-content .hljs-property { |
| color: var(--color-primary) !important; |
| } |
| |
| .tool-call-content .hljs-attr, |
| .tool-result-content .hljs-attr { |
| color: var(--color-accent) !important; |
| } |
| </style> |
|
|
| |
| <style> |
| |
| .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); |
| } |
| |
| |
| .sidebar, |
| #chat, |
| #messages { |
| scrollbar-width: thin; |
| scrollbar-color: rgba(148, 163, 184, 0.2) transparent; |
| } |
| |
| |
| .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> |
|
|