Files
LocalAI/core/http/views/chat.html
Ettore Di Giacinto 93cd688f40 chore: small ux enhancements (#7290)
* chore: improve chat attachments

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: display installed backends/models

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-11-17 17:09:42 +01:00

1227 lines
58 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;
if (Alpine.store("chat")) {
Alpine.store("chat").contextSize = __chatContextSize;
return;
}
Alpine.store("chat", {
history: [],
languages: [undefined],
systemPrompt: "",
mcpMode: false,
contextSize: __chatContextSize,
tokenUsage: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
currentRequest: null
},
clear() {
this.history.length = 0;
this.tokenUsage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
currentRequest: null
};
},
updateTokenUsage(usage) {
// 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 = this.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
this.tokenUsage.promptTokens = this.tokenUsage.promptTokens - currentRequest.promptTokens + (usage.prompt_tokens || 0);
this.tokenUsage.completionTokens = this.tokenUsage.completionTokens - currentRequest.completionTokens + (usage.completion_tokens || 0);
this.tokenUsage.totalTokens = this.tokenUsage.totalTokens - currentRequest.totalTokens + (usage.total_tokens || 0);
// Store current request usage
this.tokenUsage.currentRequest = {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
totalTokens: usage.total_tokens || 0
};
}
}
},
getRemainingTokens() {
if (!this.contextSize) return null;
return Math.max(0, this.contextSize - this.tokenUsage.totalTokens);
},
getContextUsagePercent() {
if (!this.contextSize) return null;
return Math.min(100, (this.tokenUsage.totalTokens / this.contextSize) * 100);
},
add(role, content, image, audio) {
const N = this.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 = this.mcpMode || false;
const shouldExpand = (role === "thinking" && !isMCPMode) || false;
this.history.push({ role, content, html: c, image, audio, expanded: shouldExpand });
}
// For other messages, merge if same role
else if (this.history.length && this.history[N].role === role) {
this.history[N].content += content;
this.history[N].html = DOMPurify.sanitize(
marked.parse(this.history[N].content)
);
// Merge new images and audio with existing ones
if (image && image.length > 0) {
this.history[N].image = [...(this.history[N].image || []), ...image];
}
if (audio && audio.length > 0) {
this.history[N].audio = [...(this.history[N].audio || []), ...audio];
}
} else {
let c = "";
const lines = content.split("\n");
lines.forEach((line) => {
c += DOMPurify.sanitize(marked.parse(line));
});
this.history.push({
role,
content,
html: c,
image: image || [],
audio: audio || []
});
}
// 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(
this.history[this.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() {
return this.history.map((message) => ({
role: message.role,
content: message.content,
image: message.image,
audio: message.audio,
}));
},
});
}
// 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();
}
</script>
<script defer src="static/chat.js"></script>
{{ $allGalleryConfigs:=.GalleryConfig }}
{{ $model:=.Model}}
<body class="bg-[#101827] text-[#E5E7EB] flex flex-col h-screen" x-data="{ sidebarOpen: true }">
{{template "views/partials/navbar" .}}
<!-- Main container with sidebar toggle -->
<div class="flex flex-1 overflow-hidden relative">
<!-- Sidebar -->
<div
class="sidebar bg-[#1E293B] fixed top-16 bottom-0 left-0 w-64 transform transition-transform duration-300 ease-in-out z-30 border-r border-[#101827] overflow-y-auto"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<div class="p-4 flex justify-between items-center border-b border-[#101827]">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-[#E5E7EB]">Chat Settings</h2>
<a
href="https://localai.io/features/text-generation/"
target="_blank"
class="text-[#94A3B8] hover:text-[#38BDF8] transition-colors"
title="Documentation">
<i class="fas fa-book text-sm"></i>
</a>
</div>
<button
@click="sidebarOpen = false"
class="text-[#94A3B8] hover:text-[#E5E7EB] focus:outline-none">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Sidebar content -->
<div class="p-4 space-y-6">
<!-- Model selection - Fixed to properly select current model -->
<div class="space-y-2">
<label class="text-sm font-medium text-[#94A3B8]">Select Model</label>
<select
id="modelSelector"
class="w-full bg-[#101827] text-[#E5E7EB] border border-[#1E293B] focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50 rounded-md shadow-sm p-2 appearance-none"
onchange="window.location = this.value"
>
<option value="" disabled class="text-[#94A3B8]">Select a model</option>
{{ range .ModelsConfig }}
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_CHAT" }}
<option
value="chat/{{$cfg.Name}}"
{{ if eq $cfg.Name $model }} selected {{end}}
class="bg-[#101827] text-[#E5E7EB]"
>
{{$cfg.Name}}
</option>
{{ end }}
{{ end }}
{{ end }}
{{ range .ModelsWithoutConfig }}
<option
value="chat/{{.}}"
{{ if eq . $model }} selected {{ end }}
class="bg-[#101827] text-[#E5E7EB]"
>
{{.}}
</option>
{{end}}
</select>
</div>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<!-- Model info -->
<div class="space-y-2">
<div class="flex items-center">
{{ if $galleryConfig.Icon }}<img src="{{$galleryConfig.Icon}}" class="rounded-lg w-8 h-8 mr-2">{{end}}
<h3 class="text-md font-medium">{{ $model }}</h3>
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="ml-2 text-[#94A3B8] hover:text-[#38BDF8] transition-colors"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
title="Model Information">
<i class="fas fa-info-circle text-sm"></i>
</button>
<button
@click="$store.chat.clear()"
id="clear"
title="Clear chat history"
class="ml-2 text-[#94A3B8] hover:text-[#38BDF8] transition-colors">
<i class="fa-solid fa-trash-can text-sm"></i>
</button>
</div>
</div>
{{ end }}
{{ end }}
<div x-data="{ showPromptForm: false }" class="space-y-3">
<!-- Token Usage Statistics -->
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-semibold text-[#E5E7EB] flex items-center">
<i class="fas fa-chart-line mr-2 text-[#38BDF8]"></i>
Token Usage
</h4>
</div>
<div class="space-y-1.5 text-xs">
<div class="flex justify-between text-[#94A3B8]">
<span>Prompt:</span>
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.promptTokens)"></span>
</div>
<div class="flex justify-between text-[#94A3B8]">
<span>Completion:</span>
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.completionTokens)"></span>
</div>
<div class="flex justify-between text-[#94A3B8] border-t border-[#101827] pt-1.5">
<span class="font-semibold text-[#38BDF8]">Total:</span>
<span class="text-[#E5E7EB] font-bold" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.totalTokens)"></span>
</div>
</div>
</div>
<!-- Context Size Indicator -->
<template x-if="$store.chat.contextSize && $store.chat.contextSize > 0">
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-semibold text-[#E5E7EB] flex items-center">
<i class="fas fa-database mr-2 text-[#38BDF8]"></i>
Context Window
</h4>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs text-[#94A3B8] mb-1">
<span>Used / Available</span>
<span class="text-[#E5E7EB] font-medium">
<span x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.totalTokens)"></span>
/
<span x-text="new Intl.NumberFormat().format($store.chat.contextSize)"></span>
</span>
</div>
<div class="w-full bg-[#101827] rounded-full h-2 overflow-hidden border border-[#1E293B]">
<div class="h-full rounded-full transition-all duration-300 ease-out"
:class="{
'bg-gradient-to-r from-[#38BDF8] to-[#8B5CF6]': $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>
<div class="flex justify-between text-xs">
<span class="text-[#94A3B8]">
Remaining:
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.getRemainingTokens())"></span>
</span>
<span class="text-[#94A3B8]">
<span x-text="Math.round($store.chat.getContextUsagePercent())"></span>%
</span>
</div>
<div x-show="$store.chat.getContextUsagePercent() >= 80" class="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-300 text-xs">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span x-show="$store.chat.getContextUsagePercent() >= 95">Context window nearly full!</span>
<span x-show="$store.chat.getContextUsagePercent() >= 80 && $store.chat.getContextUsagePercent() < 95">Approaching context limit</span>
</div>
</div>
</div>
</template>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
{{ $modelConfig := "" }}
{{ range .ModelsConfig }}
{{ if eq .Name $model }}
{{ $modelConfig = . }}
{{ end }}
{{ end }}
{{ if and $modelConfig (or (ne $modelConfig.MCP.Servers "") (ne $modelConfig.MCP.Stdio "")) }}
<!-- MCP Toggle -->
<div class="flex items-center justify-between px-3 py-2 text-sm rounded text-[#E5E7EB] bg-[#1E293B] border border-[#38BDF8]/20">
<span><i class="fa-solid fa-plug mr-2 text-[#38BDF8]"></i> Agentic MCP Mode</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="mcp-toggle" class="sr-only peer" x-model="$store.chat.mcpMode">
<div class="w-11 h-6 bg-[#101827] peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#38BDF8]/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-[#1E293B] after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#38BDF8]"></div>
</label>
</div>
<!-- MCP Mode Notification -->
<div x-show="$store.chat.mcpMode" class="p-3 bg-[#38BDF8]/10 border border-[#38BDF8]/30 rounded text-[#94A3B8] text-xs">
<div class="flex items-start space-x-2">
<i class="fa-solid fa-info-circle text-[#38BDF8] mt-0.5"></i>
<div>
<p class="font-medium text-[#E5E7EB] mb-1">Non-streaming Mode Active</p>
<p class="text-[#94A3B8]">Responses will be processed in full before display. This may take significantly longer (up to 5 minutes), especially on CPU-only systems.</p>
</div>
</div>
</div>
{{ end }}
{{ end }}
{{ end }}
<button
@click="showPromptForm = !showPromptForm"
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded text-[#E5E7EB] bg-[#1E293B] hover:bg-[#1E293B]/80 border border-[#38BDF8]/20 hover:border-[#38BDF8]/40 transition-colors glow-on-hover"
>
<span><i class="fa-solid fa-message mr-2 text-[#38BDF8]"></i> System Prompt</span>
<i :class="showPromptForm ? 'fa-chevron-up' : 'fa-chevron-down'" class="fa-solid"></i>
</button>
<div x-show="showPromptForm" x-data="{
showToast: false,
previousPrompt: $store.chat.systemPrompt,
isUpdated() {
if (this.previousPrompt !== $store.chat.systemPrompt) {
this.showToast = true;
this.previousPrompt = $store.chat.systemPrompt;
setTimeout(() => {this.showToast = false;}, 2000);
}
}
}" class="p-3 bg-[#1E293B] border border-[#38BDF8]/20 rounded-lg">
<form id="system_prompt" @submit.prevent="isUpdated" class="flex flex-col space-y-2">
<textarea
type="text"
id="systemPrompt"
name="systemPrompt"
class="bg-[#101827] text-[#E5E7EB] border border-[#1E293B] focus:border-[#38BDF8] focus:ring focus:ring-[#38BDF8] focus:ring-opacity-50 rounded-md shadow-sm p-2 appearance-none min-h-24 placeholder-[#94A3B8]"
placeholder="System prompt"
x-model.lazy="$store.chat.systemPrompt"
></textarea>
<div
x-show="showToast"
x-transition
class="mb-2 text-green-400 px-4 py-2 text-sm text-center bg-green-500/10 border border-green-500/30 rounded"
>
System prompt updated!
</div>
<button
type="submit"
class="px-3 py-2 text-sm rounded text-[#101827] bg-[#38BDF8] hover:bg-[#38BDF8]/90 transition-colors font-medium"
>
Save System Prompt
</button>
</form>
</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-64' : 'ml-0'">
<!-- Chat header with toggle button -->
<div class="border-b border-[#1E293B] 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-[#94A3B8] hover:text-[#E5E7EB] focus:outline-none bg-[#1E293B] hover:bg-[#1E293B]/80 p-2 rounded transition-colors"
style="min-width: 36px;"
title="Toggle settings">
<i class="fa-solid" :class="sidebarOpen ? 'fa-times' : 'fa-bars'"></i>
</button>
<div class="flex items-center">
<i class="fa-solid fa-comments mr-2 text-[#38BDF8]"></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-[#E5E7EB]">
Chat {{ if .Model }} with {{.Model}} {{ end }}
</h1>
<!-- Loading indicator next to model name -->
<div id="header-loading-indicator" class="ml-3 text-[#38BDF8]" style="display: none;">
<i class="fas fa-spinner fa-spin text-sm"></i>
</div>
</div>
</div>
</div>
<!-- Chat messages area -->
<div class="flex-1 p-4 overflow-auto" id="chat" x-data="{history: $store.chat.history}">
<p id="usage" x-show="history.length === 0" class="text-[#94A3B8]">
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-[#38BDF8]"></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-[#38BDF8]"></i> icon.</li>
<li>To send a text, markdown or PDF file, click the <i class="fa-solid fa-file text-[#38BDF8]"></i> icon.</li>
</ul>
</p>
<div id="messages" class="max-w-3xl mx-auto space-y-2">
<template x-for="(message, index) in history" :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-[#38BDF8]/10 text-[#94A3B8] border border-[#38BDF8]/30">
<button
@click="message.expanded = !message.expanded"
class="w-full flex items-center justify-between text-left hover:bg-[#38BDF8]/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-[#38BDF8]"></i>
<span class="text-xs font-semibold text-[#38BDF8]" x-text="message.role === 'thinking' ? 'Thinking' : 'Reasoning'"></span>
<span class="text-xs text-[#94A3B8]" x-show="message.content && message.content.length > 0" x-text="'(' + Math.ceil(message.content.length / 100) + ' lines)'"></span>
</div>
<i
class="fa-solid text-[#38BDF8] 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-[#38BDF8]/20"
>
<div
class="text-[#E5E7EB] 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">
<div class="flex flex-col flex-1">
<div class="p-2 flex-1 rounded-lg bg-[#8B5CF6]/10 text-[#94A3B8] border border-[#8B5CF6]/30">
<button
@click="message.expanded = !message.expanded"
class="w-full flex items-center justify-between text-left hover:bg-[#8B5CF6]/20 rounded p-2 transition-colors"
>
<div class="flex items-center space-x-2">
<i class="fa-solid fa-wrench text-[#8B5CF6]"></i>
<span class="text-xs font-semibold text-[#8B5CF6]">Tool Call</span>
<span class="text-xs text-[#94A3B8]" x-text="getToolName(message.content)"></span>
</div>
<i
class="fa-solid text-[#8B5CF6] 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-[#8B5CF6]/20"
>
<div class="text-[#E5E7EB] text-xs max-h-96 overflow-auto overflow-x-auto tool-call-content"
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">
<div class="flex flex-col flex-1">
<div class="p-2 flex-1 rounded-lg bg-[#10B981]/10 text-[#94A3B8] border border-[#10B981]/30">
<button
@click="message.expanded = !message.expanded"
class="w-full flex items-center justify-between text-left hover:bg-[#10B981]/20 rounded p-2 transition-colors"
>
<div class="flex items-center space-x-2">
<i class="fa-solid fa-check-circle text-[#10B981]"></i>
<span class="text-xs font-semibold text-[#10B981]">Tool Result</span>
<span class="text-xs text-[#94A3B8]" x-text="getToolName(message.content) || 'Success'"></span>
</div>
<i
class="fa-solid text-[#10B981] 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-[#10B981]/20"
>
<div class="text-[#E5E7EB] text-xs max-h-96 overflow-auto overflow-x-auto tool-result-content"
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-[#94A3B8] mb-1">You</span>
<div class="p-3 flex-1 rounded-lg bg-gradient-to-br from-[#1E293B] to-[#101827] text-[#E5E7EB] border border-[#38BDF8]/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-[#38BDF8]/20">{{end}}
{{ end }}
<div class="flex flex-col flex-1">
<span class="text-xs font-semibold text-[#94A3B8] mb-1">{{if .Model}}{{.Model}}{{else}}Assistant{{end}}</span>
<div class="flex-1 text-[#E5E7EB] flex items-center space-x-2">
<div class="p-3 rounded-lg bg-gradient-to-br from-[#1E293B] to-[#101827] border border-[#8B5CF6]/20 shadow-lg" x-html="message.html"></div>
<button @click="copyToClipboard(message.html)" title="Copy to clipboard" class="text-[#94A3B8] hover:text-[#38BDF8] transition-colors p-1">
<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-[#38BDF8]' : 'fa-robot text-[#8B5CF6]'"
></i>
{{ end }}
</div>
</div>
</template>
</div>
</div>
<!-- Chat Input -->
<div class="p-4 border-t border-[#1E293B]" 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-[#38BDF8]/20 border border-[#38BDF8]/40 text-[#E5E7EB]">
<i :class="file.type === 'image' ? 'fa-solid fa-image' : file.type === 'audio' ? 'fa-solid fa-microphone' : 'fa-solid fa-file'" class="text-[#38BDF8]"></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-[#94A3B8] hover:text-[#E5E7EB] 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-[#94A3B8]">
<div class="flex items-center gap-1">
<i class="fas fa-chart-line text-[#38BDF8]"></i>
<span>Prompt:</span>
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.promptTokens)"></span>
</div>
<div class="flex items-center gap-1">
<span>Completion:</span>
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.completionTokens)"></span>
</div>
<div class="flex items-center gap-1 border-l border-[#1E293B] pl-3">
<span class="text-[#38BDF8] font-semibold">Total:</span>
<span class="text-[#E5E7EB] font-bold" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.totalTokens)"></span>
</div>
<!-- Tokens per second display -->
<div id="tokens-per-second-container" class="flex items-center gap-1 border-l border-[#1E293B] pl-3">
<i class="fas fa-tachometer-alt text-[#38BDF8]"></i>
<span id="tokens-per-second" class="text-[#E5E7EB] font-medium">-</span>
</div>
</div>
<!-- Context Window -->
<template x-if="$store.chat.contextSize && $store.chat.contextSize > 0">
<div class="flex items-center gap-2 text-[#94A3B8]">
<i class="fas fa-database text-[#38BDF8]"></i>
<span>
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.tokenUsage.totalTokens)"></span>
/
<span class="text-[#E5E7EB] font-medium" x-text="new Intl.NumberFormat().format($store.chat.contextSize)"></span>
</span>
<div class="w-16 bg-[#101827] rounded-full h-1.5 overflow-hidden border border-[#1E293B]">
<div class="h-full rounded-full transition-all duration-300 ease-out"
:class="{
'bg-gradient-to-r from-[#38BDF8] to-[#8B5CF6]': $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-[#94A3B8]" x-text="Math.round($store.chat.getContextUsagePercent()) + '%'"></span>
<span x-show="$store.chat.getContextUsagePercent() >= 80" class="text-yellow-400">
<i class="fas fa-exclamation-triangle"></i>
</span>
</div>
</template>
</div>
<div class="relative w-full bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl shadow-lg focus-within:ring-2 focus-within:ring-[#38BDF8]/50 focus-within:border-[#38BDF8] transition-all duration-200">
<textarea
id="input"
name="input"
x-model="inputValue"
placeholder="Send a message..."
class="p-3 pr-16 w-full bg-[#1E293B] text-[#E5E7EB] placeholder-[#94A3B8] 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-[#94A3B8] absolute right-12 top-3 text-base p-1.5 hover:text-[#38BDF8] transition-colors duration-200"
title="Attach images"
></button>
<button
type="button"
onclick="document.getElementById('input_audio').click()"
class="fa-solid fa-microphone text-[#94A3B8] absolute right-20 top-3 text-base p-1.5 hover:text-[#38BDF8] 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-[#94A3B8] absolute right-28 top-3 text-base p-1.5 hover:text-[#38BDF8] 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-red-400 hover:text-red-500 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-[#94A3B8] hover:text-[#38BDF8] 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-[#101827] p-3 rounded border border-[#10B981]/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-[#101827] p-3 rounded border border-[#10B981]/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-[#101827] p-3 rounded border border-[#10B981]/20 overflow-x-auto text-xs">' + content.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>');
}
}
};
// Get tool name from content
window.getToolName = (content) => {
if (!content || typeof content !== 'string') return '';
try {
const parsed = JSON.parse(content);
return parsed.name || '';
} catch (e) {
// Try to extract name from string
const nameMatch = content.match(/"name"\s*:\s*"([^"]+)"/);
return nameMatch ? nameMatch[1] : '';
}
};
});
// 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 pre,
.tool-result-content pre {
overflow-x: auto;
overflow-y: auto;
max-width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
background: #101827 !important;
border: 1px solid #1E293B;
border-radius: 6px;
padding: 12px;
margin: 0;
}
.tool-call-content code,
.tool-result-content code {
word-wrap: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
background: transparent !important;
color: #E5E7EB;
font-family: 'ui-monospace', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
/* 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: #8B5CF6 !important;
font-weight: 600;
}
.tool-call-content .hljs-string,
.tool-result-content .hljs-string {
color: #10B981 !important;
}
.tool-call-content .hljs-number,
.tool-result-content .hljs-number {
color: #38BDF8 !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: #38BDF8 !important;
}
.tool-call-content .hljs-attr,
.tool-result-content .hljs-attr {
color: #8B5CF6 !important;
}
</style>
</body>
</html>