Display token/sec into stats

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-11-10 15:29:59 +01:00
parent f2190ab71c
commit cc0d05eef6
4 changed files with 283 additions and 67 deletions

View File

@@ -30,22 +30,63 @@ SOFTWARE.
// Global variable to store the current AbortController
let currentAbortController = null;
let currentReader = null;
let requestStartTime = null;
let tokensReceived = 0;
let tokensPerSecondInterval = null;
let lastTokensPerSecond = null; // Store the last calculated rate
function toggleLoader(show) {
const sendButton = document.getElementById('send-button');
const stopButton = document.getElementById('stop-button');
const headerLoadingIndicator = document.getElementById('header-loading-indicator');
const tokensPerSecondDisplay = document.getElementById('tokens-per-second');
if (show) {
sendButton.style.display = 'none';
stopButton.style.display = 'block';
if (headerLoadingIndicator) headerLoadingIndicator.style.display = 'block';
// Reset token tracking
requestStartTime = Date.now();
tokensReceived = 0;
// Start updating tokens/second display
if (tokensPerSecondDisplay) {
tokensPerSecondDisplay.textContent = '-';
updateTokensPerSecond();
tokensPerSecondInterval = setInterval(updateTokensPerSecond, 500); // Update every 500ms
}
} else {
sendButton.style.display = 'block';
stopButton.style.display = 'none';
if (headerLoadingIndicator) headerLoadingIndicator.style.display = 'none';
// Stop updating but keep the last value visible
if (tokensPerSecondInterval) {
clearInterval(tokensPerSecondInterval);
tokensPerSecondInterval = null;
}
// Keep the last calculated rate visible
if (tokensPerSecondDisplay && lastTokensPerSecond !== null) {
tokensPerSecondDisplay.textContent = lastTokensPerSecond;
}
currentAbortController = null;
currentReader = null;
requestStartTime = null;
tokensReceived = 0;
}
}
function updateTokensPerSecond() {
const tokensPerSecondDisplay = document.getElementById('tokens-per-second');
if (!tokensPerSecondDisplay || !requestStartTime) return;
const elapsedSeconds = (Date.now() - requestStartTime) / 1000;
if (elapsedSeconds > 0 && tokensReceived > 0) {
const rate = tokensReceived / elapsedSeconds;
const formattedRate = `${rate.toFixed(1)} tokens/s`;
tokensPerSecondDisplay.textContent = formattedRate;
lastTokensPerSecond = formattedRate; // Store the last calculated rate
} else if (elapsedSeconds > 0) {
tokensPerSecondDisplay.textContent = '-';
}
}
@@ -227,6 +268,11 @@ function processAndSendMessage(inputValue) {
if (input) input.value = "";
const systemPrompt = localStorage.getItem("system_prompt");
Alpine.nextTick(() => { document.getElementById('messages').scrollIntoView(false); });
// Reset token tracking before starting new request
requestStartTime = Date.now();
tokensReceived = 0;
promptGPT(systemPrompt, fullInput);
// Reset file contents and names after sending
@@ -412,6 +458,10 @@ async function promptGPT(systemPrompt, input) {
const content = data.choices[0]?.text || "";
if (content) {
// Count tokens for rate calculation (MCP mode - full content at once)
tokensReceived += Math.ceil(content.length / 4);
updateTokensPerSecond();
// Process thinking tags using shared function
const { regularContent, thinkingContent } = processThinkingTags(content);
@@ -461,6 +511,9 @@ async function promptGPT(systemPrompt, input) {
const addToChat = (token) => {
const chatStore = Alpine.store("chat");
chatStore.add("assistant", token);
// Count tokens for rate calculation (rough estimate: count characters/4)
tokensReceived += Math.ceil(token.length / 4);
updateTokensPerSecond();
// Efficiently scroll into view without triggering multiple reflows
// const messages = document.getElementById('messages');
// messages.scrollTop = messages.scrollHeight;
@@ -521,6 +574,9 @@ async function promptGPT(systemPrompt, input) {
// Handle content based on thinking state
if (isThinking) {
thinkingContent += token;
// Count tokens for rate calculation
tokensReceived += Math.ceil(token.length / 4);
updateTokensPerSecond();
// Update the last thinking message or create a new one
if (lastThinkingMessageIndex === -1) {
// Create new thinking message

View File

@@ -305,7 +305,7 @@
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</div>
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-html="renderMarkdown(selectedBackend?.description)"></div>
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full markdown-content" x-html="renderMarkdown(selectedBackend?.description)"></div>
<template x-if="selectedBackend?.tags && selectedBackend.tags.length > 0">
<div>
<p class="text-sm mb-3 font-semibold text-gray-900 dark:text-white">Tags</p>
@@ -439,6 +439,42 @@ tbody tr:last-child td:first-child {
tbody tr:last-child td:last-child {
border-bottom-right-radius: 1rem;
}
/* Markdown content overflow handling */
.markdown-content {
word-wrap: break-word;
overflow-wrap: anywhere;
max-width: 100%;
}
.markdown-content pre {
overflow-x: auto;
max-width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-content code {
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdown-content pre code {
white-space: pre;
overflow-x: auto;
display: block;
}
.markdown-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
</style>
<script>
@@ -602,6 +638,9 @@ function backendsGallery() {
renderMarkdown(text) {
if (!text) return '';
try {
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
return text; // Return plain text if libraries not loaded
}
const html = marked.parse(text);
return DOMPurify.sanitize(html);
} catch (error) {

View File

@@ -202,7 +202,16 @@ SOFTWARE.
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<div class="p-4 flex justify-between items-center border-b border-[#101827]">
<h2 class="text-lg font-semibold text-[#E5E7EB]">Chat Settings</h2>
<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">
@@ -256,33 +265,28 @@ SOFTWARE.
<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>
<button data-twe-ripple-init data-twe-ripple-color="light" class="w-full text-left flex items-center px-3 py-2 text-xs rounded text-[#E5E7EB] bg-[#101827] hover:bg-[#101827]/80 border border-[#38BDF8]/20 transition-colors" data-modal-target="model-info-modal" data-modal-toggle="model-info-modal">
<i class="fas fa-info-circle mr-2 text-[#38BDF8]"></i>
Model Information
</button>
</div>
{{ end }}
{{ end }}
<div x-data="{ showPromptForm: false }" class="space-y-3">
<!-- Actions -->
<button
@click="$store.chat.clear()"
id="clear"
title="Clear chat history"
class="w-full flex items-center px-3 py-2 text-sm rounded text-[#E5E7EB] bg-[#101827] hover:bg-[#101827]/80 border border-[#1E293B] transition-colors"
>
<i class="fa-solid fa-trash-can mr-2"></i> Clear chat
</button>
<a
href="https://localai.io/features/text-generation/"
target="_blank"
class="w-full flex items-center 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"
>
<i class="fas fa-book mr-2 text-[#38BDF8]"></i> Documentation
</a>
<!-- 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">
@@ -588,6 +592,11 @@ SOFTWARE.
<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 -->
@@ -730,7 +739,7 @@ SOFTWARE.
<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">{{ $galleryConfig.Description }}</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>
@@ -771,57 +780,130 @@ SOFTWARE.
// Context size is now initialized in the Alpine store initialization above
// Process markdown in model info modal when it opens
document.addEventListener('DOMContentLoaded', () => {
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) {
// Process markdown on initial load
const processMarkdown = () => {
if (descriptionElement && typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
const originalText = descriptionElement.textContent || descriptionElement.innerText;
if (originalText) {
try {
const html = marked.parse(originalText);
descriptionElement.innerHTML = DOMPurify.sanitize(html);
} catch (error) {
console.error('Error rendering markdown:', error);
}
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);
}
}
};
// Process immediately if modal is already visible
if (!modalElement.classList.contains('hidden')) {
processMarkdown();
}
// Listen for modal show events (Flowbite uses data-modal-show attribute changes)
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-hidden') {
const isHidden = modalElement.getAttribute('aria-hidden') === 'true';
if (!isHidden) {
// Modal is now visible, process markdown
setTimeout(processMarkdown, 100);
}
}
});
});
});
observer.observe(modalElement, {
attributes: true,
attributeFilter: ['aria-hidden', 'class']
});
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, 200);
});
// 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;
}
</style>
</body>
</html>

View File

@@ -359,7 +359,7 @@
class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</div>
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-html="renderMarkdown(selectedModel?.description)"></div>
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full markdown-content" x-html="renderMarkdown(selectedModel?.description)"></div>
<hr>
<template x-if="selectedModel?.urls && selectedModel.urls.length > 0">
<div>
@@ -495,6 +495,42 @@ tbody tr:last-child td:first-child {
tbody tr:last-child td:last-child {
border-bottom-right-radius: 1rem;
}
/* Markdown content overflow handling */
.markdown-content {
word-wrap: break-word;
overflow-wrap: anywhere;
max-width: 100%;
}
.markdown-content pre {
overflow-x: auto;
max-width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdown-content code {
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdown-content pre code {
white-space: pre;
overflow-x: auto;
display: block;
}
.markdown-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
</style>
<script>
@@ -672,6 +708,9 @@ function modelsGallery() {
renderMarkdown(text) {
if (!text) return '';
try {
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
return text; // Return plain text if libraries not loaded
}
const html = marked.parse(text);
return DOMPurify.sanitize(html);
} catch (error) {