Files
LocalAI/core/http/views/chat.html
Ettore Di Giacinto 8ac7e8c299 fix(chat-ui): model selection toggle and new chat (#7574)
Fixes a minor glitch that happens when switching model in from the chat
pane where the header was not getting updated. Besides, it allows to
create new chat directly when clicking from the management pane to the
model.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-12-14 22:29:11 +01:00

1995 lines
95 KiB
HTML

<!--
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.
-->
<!doctype html>
<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
window.__galleryConfigs = {};
{{ $allGalleryConfigs:=.GalleryConfig }}
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
window.__galleryConfigs["{{$modelName}}"] = {};
{{ if $galleryConfig.Icon }}
window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}";
{{ 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) {
// 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;
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 is expanded by default in non-MCP mode, collapsed in MCP mode
// Reasoning, tool_call, and tool_result are always collapsed by default
const isMCPMode = chat.mcpMode || false;
const shouldExpand = (role === "thinking" && !isMCPMode) || false;
chat.history.push({ role, content, html: c, image, audio, expanded: shouldExpand });
// 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];
}
} 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 || []
});
// 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();
// 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)
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) {
modelSelector.selectedIndex = i;
break;
}
}
// Trigger Alpine reactivity by dispatching change event
modelSelector.dispatchEvent(new Event('change', { bubbles: true }));
}
// 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">
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<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"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
{{ end }}
{{ end }}
{{ 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 }}
</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">
{{ if $galleryConfig }}
{{ if $galleryConfig.Icon }}<img src="{{$galleryConfig.Icon}}" class="rounded-lg mt-2 max-w-8 max-h-8 border border-[var(--color-primary-border)]/20">{{end}}
{{ end }}
<div class="flex flex-col flex-1">
<span class="text-xs font-semibold text-[var(--color-text-secondary)] mb-1">{{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 -->
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<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 md:inset-0 h-[calc(100%-1rem)] max-h-full">
<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 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</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">
<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">
{{ if $galleryConfig.Icon }}<img class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" src="{{$galleryConfig.Icon}}" loading="lazy"/>{{end}}
</div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full">{{ $galleryConfig.Description }}</div>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul>
{{range $galleryConfig.URLs}}
<li><a href="{{ . }}" target="_blank">{{ . }}</a></li>
{{end}}
</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">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
<!-- 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, '&lt;').replace(/>/g, '&gt;') + '</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
document.querySelectorAll('[data-modal-toggle="model-info-modal"]').forEach(button => {
button.addEventListener('click', () => {
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();
}
// Also sync after Alpine initializes (in case it runs after DOMContentLoaded)
if (window.Alpine) {
Alpine.nextTick(syncModelSelectorOnLoad);
} else {
document.addEventListener('alpine:init', () => {
Alpine.nextTick(syncModelSelectorOnLoad);
});
}
</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 !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;
}
/* 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 !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>
<!-- 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>