mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-29 01:19:32 -05:00
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>
1995 lines
95 KiB
HTML
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, '<').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();
|
|
}
|
|
|
|
// 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>
|