mirror of
https://github.com/mudler/LocalAI.git
synced 2026-01-01 07:01:09 -06:00
Otherwise tool call and result might overflow the box Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1932 lines
92 KiB
HTML
1932 lines
92 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 }}
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Trigger MCP availability check in Alpine component
|
|
// Dispatch a custom event that the MCP toggle component can listen to
|
|
const modelSelector = document.getElementById('modelSelector');
|
|
if (modelSelector) {
|
|
// Trigger Alpine reactivity by dispatching change event
|
|
modelSelector.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
</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">
|
|
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">Model</label>
|
|
{{ 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"
|
|
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"
|
|
title="Edit Model Configuration">
|
|
<i class="fas fa-edit"></i>
|
|
</a>
|
|
{{ end }}
|
|
</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>
|
|
{{ if $model }}
|
|
{{ $galleryConfig:= index $allGalleryConfigs $model}}
|
|
{{ if $galleryConfig }}
|
|
{{ if $galleryConfig.Icon }}<img src="{{$galleryConfig.Icon}}" class="rounded-lg w-8 h-8 mr-2">{{end}}
|
|
{{ end }}
|
|
{{ end }}
|
|
<h1 class="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
Chat {{ if .Model }} with {{.Model}} {{ end }}
|
|
</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, '<').replace(/>/g, '>') + '</pre>');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Get tool name from content
|
|
window.getToolName = (content) => {
|
|
if (!content || typeof content !== 'string') return '';
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return parsed.name || '';
|
|
} catch (e) {
|
|
// Try to extract name from string
|
|
const nameMatch = content.match(/"name"\s*:\s*"([^"]+)"/);
|
|
return nameMatch ? nameMatch[1] : '';
|
|
}
|
|
};
|
|
|
|
// Chat management functions are defined in chat.js and available globally
|
|
// These are just placeholders - the actual implementations are in chat.js
|
|
|
|
// Get last message preview for chat list
|
|
window.getLastMessagePreview = (chat) => {
|
|
if (!chat || !chat.history || chat.history.length === 0) {
|
|
return 'No messages yet';
|
|
}
|
|
const lastMessage = chat.history[chat.history.length - 1];
|
|
if (!lastMessage || !lastMessage.content) {
|
|
return 'No messages yet';
|
|
}
|
|
// Get plain text from content (remove HTML tags)
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = lastMessage.content;
|
|
const text = tempDiv.textContent || tempDiv.innerText || '';
|
|
return text.substring(0, 40) + (text.length > 40 ? '...' : '');
|
|
};
|
|
|
|
// Format chat date for display
|
|
window.formatChatDate = (timestamp) => {
|
|
if (!timestamp) return '';
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) {
|
|
return 'Just now';
|
|
} else if (diffMins < 60) {
|
|
return `${diffMins}m ago`;
|
|
} else if (diffHours < 24) {
|
|
return `${diffHours}h ago`;
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays}d ago`;
|
|
} else {
|
|
// Show date for older chats
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
}
|
|
};
|
|
});
|
|
|
|
// Context size is now initialized in the Alpine store initialization above
|
|
|
|
// Process markdown in model info modal when it opens
|
|
function initMarkdownProcessing() {
|
|
// Wait for marked and DOMPurify to be available
|
|
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
|
setTimeout(initMarkdownProcessing, 100);
|
|
return;
|
|
}
|
|
|
|
const modalElement = document.getElementById('model-info-modal');
|
|
const descriptionElement = document.getElementById('model-info-description');
|
|
|
|
if (!modalElement || !descriptionElement) {
|
|
return;
|
|
}
|
|
|
|
// Store original text in data attribute if not already stored
|
|
let originalText = descriptionElement.dataset.originalText;
|
|
if (!originalText) {
|
|
originalText = descriptionElement.textContent || descriptionElement.innerText;
|
|
descriptionElement.dataset.originalText = originalText;
|
|
}
|
|
|
|
// Process markdown function
|
|
const processMarkdown = () => {
|
|
if (!descriptionElement || !originalText) return;
|
|
|
|
try {
|
|
// Check if already processed (has HTML tags that look like markdown output)
|
|
const currentContent = descriptionElement.innerHTML.trim();
|
|
if (currentContent.startsWith('<') && (currentContent.includes('<p>') || currentContent.includes('<h') || currentContent.includes('<ul>') || currentContent.includes('<ol>'))) {
|
|
return; // Already processed
|
|
}
|
|
|
|
// Use stored original text
|
|
const textToProcess = descriptionElement.dataset.originalText || originalText;
|
|
if (textToProcess && textToProcess.trim()) {
|
|
const html = marked.parse(textToProcess);
|
|
descriptionElement.innerHTML = DOMPurify.sanitize(html);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error rendering markdown:', error);
|
|
}
|
|
};
|
|
|
|
// Process immediately if modal is already visible
|
|
if (!modalElement.classList.contains('hidden')) {
|
|
processMarkdown();
|
|
}
|
|
|
|
// Listen for modal show events - check both aria-hidden and class changes
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === 'attributes') {
|
|
const isHidden = modalElement.classList.contains('hidden') ||
|
|
modalElement.getAttribute('aria-hidden') === 'true';
|
|
if (!isHidden) {
|
|
// Modal is now visible, process markdown
|
|
setTimeout(processMarkdown, 150);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(modalElement, {
|
|
attributes: true,
|
|
attributeFilter: ['aria-hidden', 'class'],
|
|
childList: false,
|
|
subtree: false
|
|
});
|
|
|
|
// Also listen for click events on modal toggle buttons
|
|
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();
|
|
}
|
|
</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>
|