mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-28 17:09:40 -05:00
* 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>
1227 lines
58 KiB
HTML
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, '<').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] : '';
|
|
}
|
|
};
|
|
});
|
|
|
|
// 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>
|