feat(ui): chat stats, small visual enhancements (#7223)
* feat(ui): show stats in chat, improve style
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Markdown, small improvements
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Display token/sec into stats
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Minor enhancement
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* Revert "Fixups"
This reverts commit ab1b3d6da9.
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
8432915cb8
commit
8876073f5c
@@ -2,6 +2,7 @@
|
||||
[build]
|
||||
cmd = "make build"
|
||||
bin = "./local-ai"
|
||||
args_bin = [ "--debug" ]
|
||||
include_ext = ["go", "html", "yaml", "toml", "json", "txt", "md"]
|
||||
exclude_dir = ["pkg/grpc/proto"]
|
||||
delay = 1000
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/utils"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -167,11 +168,12 @@ func (re *RequestExtractor) SetOpenAIRequest(ctx *fiber.Ctx) error {
|
||||
c1, cancel := context.WithCancel(re.applicationConfig.Context)
|
||||
// Monitor the Fiber context and cancel our context when it's canceled
|
||||
// This ensures we respect request cancellation without causing panics
|
||||
go func() {
|
||||
<-ctx.Context().Done()
|
||||
// Fiber context was canceled (request completed or client disconnected)
|
||||
cancel()
|
||||
}()
|
||||
go func(fiberCtx *fasthttp.RequestCtx) {
|
||||
if fiberCtx != nil {
|
||||
<-fiberCtx.Done()
|
||||
cancel()
|
||||
}
|
||||
}(ctx.Context())
|
||||
// Add the correlation ID to the new context
|
||||
ctxWithCorrelationID := context.WithValue(c1, CorrelationIDKey, correlationID)
|
||||
|
||||
|
||||
@@ -91,11 +91,15 @@ func RegisterUIRoutes(app *fiber.App,
|
||||
}
|
||||
|
||||
title := "LocalAI - Chat"
|
||||
var modelContextSize *int
|
||||
|
||||
for _, b := range modelConfigs {
|
||||
if b.HasUsecases(config.FLAG_CHAT) {
|
||||
modelThatCanBeUsed = b.Name
|
||||
title = "LocalAI - Chat with " + modelThatCanBeUsed
|
||||
if b.LLMConfig.ContextSize != nil {
|
||||
modelContextSize = b.LLMConfig.ContextSize
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -107,6 +111,7 @@ func RegisterUIRoutes(app *fiber.App,
|
||||
"GalleryConfig": galleryConfigs,
|
||||
"ModelsConfig": modelConfigs,
|
||||
"Model": modelThatCanBeUsed,
|
||||
"ContextSize": modelContextSize,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
@@ -120,6 +125,8 @@ func RegisterUIRoutes(app *fiber.App,
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
galleryConfigs := map[string]*gallery.ModelConfig{}
|
||||
modelName := c.Params("model")
|
||||
var modelContextSize *int
|
||||
|
||||
for _, m := range modelConfigs {
|
||||
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
|
||||
@@ -127,15 +134,19 @@ func RegisterUIRoutes(app *fiber.App,
|
||||
continue
|
||||
}
|
||||
galleryConfigs[m.Name] = cfg
|
||||
if m.Name == modelName && m.LLMConfig.ContextSize != nil {
|
||||
modelContextSize = m.LLMConfig.ContextSize
|
||||
}
|
||||
}
|
||||
|
||||
summary := fiber.Map{
|
||||
"Title": "LocalAI - Chat with " + c.Params("model"),
|
||||
"Title": "LocalAI - Chat with " + modelName,
|
||||
"BaseURL": utils.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"GalleryConfig": galleryConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": c.Params("model"),
|
||||
"Model": modelName,
|
||||
"ContextSize": modelContextSize,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
|
||||
@@ -30,21 +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';
|
||||
document.getElementById("input").disabled = true;
|
||||
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 {
|
||||
document.getElementById("input").disabled = false;
|
||||
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 = '-';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +213,30 @@ function readInputFile() {
|
||||
|
||||
function submitPrompt(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const input = document.getElementById("input");
|
||||
if (!input) return;
|
||||
|
||||
const input = document.getElementById("input").value;
|
||||
let fullInput = input;
|
||||
const inputValue = input.value;
|
||||
if (!inputValue.trim()) return; // Don't send empty messages
|
||||
|
||||
// If already processing, abort the current request and send the new one
|
||||
if (currentAbortController || currentReader) {
|
||||
// Abort current request
|
||||
stopRequest();
|
||||
// Small delay to ensure cleanup completes
|
||||
setTimeout(() => {
|
||||
// Continue with new request
|
||||
processAndSendMessage(inputValue);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
processAndSendMessage(inputValue);
|
||||
}
|
||||
|
||||
function processAndSendMessage(inputValue) {
|
||||
let fullInput = inputValue;
|
||||
|
||||
// If there are file contents, append them to the input for the LLM
|
||||
if (fileContents.length > 0) {
|
||||
@@ -184,7 +247,7 @@ function submitPrompt(event) {
|
||||
}
|
||||
|
||||
// Show file icons in chat if there are files
|
||||
let displayContent = input;
|
||||
let displayContent = inputValue;
|
||||
if (currentFileNames.length > 0) {
|
||||
displayContent += "\n\n";
|
||||
currentFileNames.forEach(fileName => {
|
||||
@@ -201,9 +264,15 @@ function submitPrompt(event) {
|
||||
history[history.length - 1].content = fullInput;
|
||||
}
|
||||
|
||||
document.getElementById("input").value = "";
|
||||
const input = document.getElementById("input");
|
||||
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
|
||||
@@ -242,6 +311,12 @@ function readInputAudio() {
|
||||
async function promptGPT(systemPrompt, input) {
|
||||
const model = document.getElementById("chat-model").value;
|
||||
const mcpMode = Alpine.store("chat").mcpMode;
|
||||
|
||||
// Reset current request usage tracking for new request
|
||||
if (Alpine.store("chat")) {
|
||||
Alpine.store("chat").tokenUsage.currentRequest = null;
|
||||
}
|
||||
|
||||
toggleLoader(true);
|
||||
|
||||
messages = Alpine.store("chat").messages();
|
||||
@@ -373,10 +448,35 @@ async function promptGPT(systemPrompt, input) {
|
||||
// Handle MCP non-streaming response
|
||||
try {
|
||||
const data = await response.json();
|
||||
// MCP endpoint returns content in choices[0].text, not choices[0].message.content
|
||||
const content = data.choices[0]?.text || "";
|
||||
|
||||
// Update token usage if present
|
||||
if (data.usage) {
|
||||
Alpine.store("chat").updateTokenUsage(data.usage);
|
||||
}
|
||||
|
||||
// MCP endpoint returns content in choices[0].message.content (chat completion format)
|
||||
// Fallback to choices[0].text for backward compatibility (completion format)
|
||||
const content = data.choices[0]?.message?.content || data.choices[0]?.text || "";
|
||||
|
||||
if (!content && (!data.choices || data.choices.length === 0)) {
|
||||
Alpine.store("chat").add(
|
||||
"assistant",
|
||||
`<span class='error'>Error: Empty response from MCP endpoint</span>`,
|
||||
);
|
||||
toggleLoader(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (content) {
|
||||
// Count tokens for rate calculation (MCP mode - full content at once)
|
||||
// Prefer actual token count from API if available
|
||||
if (data.usage && data.usage.completion_tokens) {
|
||||
tokensReceived = data.usage.completion_tokens;
|
||||
} else {
|
||||
tokensReceived += Math.ceil(content.length / 4);
|
||||
}
|
||||
updateTokensPerSecond();
|
||||
|
||||
// Process thinking tags using shared function
|
||||
const { regularContent, thinkingContent } = processThinkingTags(content);
|
||||
|
||||
@@ -426,6 +526,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;
|
||||
@@ -456,6 +559,12 @@ async function promptGPT(systemPrompt, input) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const jsonData = JSON.parse(line.substring(6));
|
||||
|
||||
// Update token usage if present
|
||||
if (jsonData.usage) {
|
||||
Alpine.store("chat").updateTokenUsage(jsonData.usage);
|
||||
}
|
||||
|
||||
const token = jsonData.choices[0].delta.content;
|
||||
|
||||
if (token) {
|
||||
@@ -480,6 +589,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
|
||||
@@ -568,14 +680,71 @@ marked.setOptions({
|
||||
},
|
||||
});
|
||||
|
||||
// Alpine store is now initialized in chat.html inline script to ensure it's available before Alpine processes the DOM
|
||||
// Only initialize if not already initialized (to avoid duplicate initialization)
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.store("chat", {
|
||||
// Check if store already exists (initialized in chat.html)
|
||||
if (!Alpine.store("chat")) {
|
||||
// Fallback initialization (should not be needed if chat.html loads correctly)
|
||||
Alpine.store("chat", {
|
||||
history: [],
|
||||
languages: [undefined],
|
||||
systemPrompt: "",
|
||||
mcpMode: false,
|
||||
contextSize: null,
|
||||
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;
|
||||
@@ -640,5 +809,6 @@ document.addEventListener("alpine:init", () => {
|
||||
audio: message.audio,
|
||||
}));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="selectedBackend?.description"></p>
|
||||
<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>
|
||||
@@ -599,6 +635,20 @@ 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) {
|
||||
console.error('Error rendering markdown:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
|
||||
openModal(backend) {
|
||||
this.selectedBackend = backend;
|
||||
},
|
||||
|
||||
@@ -28,12 +28,167 @@ SOFTWARE.
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{{template "views/partials/head" .}}
|
||||
<script defer src="static/chat.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
// Initialize PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/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 messages, always create a new message
|
||||
if (role === "thinking") {
|
||||
let c = "";
|
||||
const lines = content.split("\n");
|
||||
lines.forEach((line) => {
|
||||
c += DOMPurify.sanitize(marked.parse(line));
|
||||
});
|
||||
this.history.push({ role, content, html: c, image, audio });
|
||||
}
|
||||
// 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 || []
|
||||
});
|
||||
}
|
||||
document.getElementById('messages').scrollIntoView(false);
|
||||
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`;
|
||||
document.head.appendChild(script);
|
||||
this.languages.push(language);
|
||||
});
|
||||
},
|
||||
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 }">
|
||||
@@ -47,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">
|
||||
@@ -101,61 +265,98 @@ 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>
|
||||
</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="{ activeTab: 'actions' }" class="space-y-4">
|
||||
<!-- Tab navigation -->
|
||||
<div class="flex border-b border-[#101827]">
|
||||
<button
|
||||
@click="activeTab = 'actions'"
|
||||
:class="activeTab === 'actions' ? 'border-b-2 border-[#38BDF8] text-[#E5E7EB]' : 'text-[#94A3B8] hover:text-[#E5E7EB]'"
|
||||
class="py-2 px-4 text-sm font-medium">
|
||||
Actions
|
||||
<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="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings' ? 'border-b-2 border-[#38BDF8] text-[#E5E7EB]' : 'text-[#94A3B8] hover:text-[#E5E7EB]'"
|
||||
class="py-2 px-4 text-sm font-medium">
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions tab -->
|
||||
<div x-show="activeTab === 'actions'" class="space-y-3">
|
||||
<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
|
||||
class="ml-2 text-[#94A3B8] hover:text-[#38BDF8] transition-colors">
|
||||
<i class="fa-solid fa-trash-can text-sm"></i>
|
||||
</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-white bg-gray-700 hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-book mr-2"></i> Documentation
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="browse?term={{.Model}}"
|
||||
class="w-full flex items-center px-3 py-2 text-sm rounded text-white bg-gray-700 hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-brain mr-2"></i> Browse Model
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<!-- Settings tab -->
|
||||
<div x-show="activeTab === 'settings'" x-data="{ showPromptForm: false }" class="space-y-3">
|
||||
{{ if $model }}
|
||||
{{ $galleryConfig:= index $allGalleryConfigs $model}}
|
||||
{{ if $galleryConfig }}
|
||||
@@ -167,21 +368,21 @@ SOFTWARE.
|
||||
{{ 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-white bg-gray-700">
|
||||
<span><i class="fa-solid fa-plug mr-2"></i> Agentic MCP Mode</span>
|
||||
<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-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 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-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<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-blue-900/20 border border-blue-700/50 rounded text-blue-100 text-xs">
|
||||
<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-blue-400 mt-0.5"></i>
|
||||
<i class="fa-solid fa-info-circle text-[#38BDF8] mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-medium text-blue-200 mb-1">Non-streaming Mode Active</p>
|
||||
<p class="text-blue-300">Responses will be processed in full before display. This may take significantly longer (up to 5 minutes), especially on CPU-only systems.</p>
|
||||
<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>
|
||||
@@ -191,9 +392,9 @@ SOFTWARE.
|
||||
|
||||
<button
|
||||
@click="showPromptForm = !showPromptForm"
|
||||
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded text-white bg-gray-700 hover:bg-gray-600 transition-colors"
|
||||
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"></i> System Prompt</span>
|
||||
<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>
|
||||
|
||||
@@ -207,32 +408,31 @@ SOFTWARE.
|
||||
setTimeout(() => {this.showToast = false;}, 2000);
|
||||
}
|
||||
}
|
||||
}" class="p-3 bg-gray-700 rounded">
|
||||
}" 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-gray-800 text-white border border-gray-600 focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 rounded-md shadow-sm p-2 appearance-none min-h-24"
|
||||
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-500 px-4 py-2 text-sm text-center"
|
||||
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-white bg-blue-600 hover:bg-blue-700 transition-colors"
|
||||
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>
|
||||
</div>
|
||||
@@ -243,38 +443,45 @@ SOFTWARE.
|
||||
:class="sidebarOpen ? 'ml-64' : 'ml-0'">
|
||||
|
||||
<!-- Chat header with toggle button -->
|
||||
<div class="border-b border-gray-700 p-4 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-gray-300 hover:text-white focus:outline-none bg-gray-800 hover:bg-gray-700 p-2 rounded"
|
||||
style="min-width: 36px;"
|
||||
title="Toggle settings">
|
||||
<i class="fa-solid" :class="sidebarOpen ? 'fa-times' : 'fa-bars'"></i>
|
||||
</button>
|
||||
|
||||
<div class="border-b border-[#1E293B] p-4 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fa-solid fa-comments mr-2"></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">
|
||||
Chat {{ if .Model }} with {{.Model}} {{ end }}
|
||||
</h1>
|
||||
<!-- 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-gray-300">
|
||||
<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">
|
||||
<li>For models that support images, you can upload an image by clicking the <i class="fa-solid fa-image"></i> icon.</li>
|
||||
<li>For models that support audio, you can upload an audio file by clicking the <i class="fa-solid fa-microphone"></i> icon.</li>
|
||||
<li>To send a text, markdown or PDF file, click the <i class="fa-solid fa-file"></i> icon.</li>
|
||||
<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">
|
||||
@@ -285,8 +492,8 @@ SOFTWARE.
|
||||
<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-gray-400">You</span>
|
||||
<div class="p-2 flex-1 rounded bg-gray-700 text-white" x-html="message.html"></div>
|
||||
<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">
|
||||
@@ -310,12 +517,12 @@ SOFTWARE.
|
||||
<template x-if="message.role === 'thinking'">
|
||||
<div class="flex items-center space-x-2 w-full">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="p-2 flex-1 rounded bg-blue-900/50 text-blue-100 border border-blue-700/50">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fa-solid fa-brain text-blue-400"></i>
|
||||
<span class="text-xs font-semibold text-blue-300">Thinking</span>
|
||||
<div class="p-3 flex-1 rounded-lg bg-[#38BDF8]/10 text-[#94A3B8] border border-[#38BDF8]/30">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fa-solid fa-brain text-[#38BDF8]"></i>
|
||||
<span class="text-xs font-semibold text-[#38BDF8]">Thinking</span>
|
||||
</div>
|
||||
<div class="mt-1" x-html="message.html"></div>
|
||||
<div class="mt-1 text-[#E5E7EB]" x-html="message.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,13 +530,13 @@ SOFTWARE.
|
||||
<template x-if="message.role != 'user' && message.role != 'thinking'">
|
||||
<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">{{end}}
|
||||
{{ 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-gray-400">{{if .Model}}{{.Model}}{{else}}Assistant{{end}}</span>
|
||||
<div class="flex-1 text-white flex items-center space-x-2">
|
||||
<div x-html="message.html"></div>
|
||||
<button @click="copyToClipboard(message.html)" title="Copy to clipboard" class="text-gray-400 hover:text-gray-100">
|
||||
<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>
|
||||
@@ -356,7 +563,7 @@ SOFTWARE.
|
||||
{{ else }}
|
||||
<i
|
||||
class="fa-solid h-8 w-8"
|
||||
:class="message.role === 'user' ? 'fa-user' : 'fa-robot'"
|
||||
:class="message.role === 'user' ? 'fa-user text-[#38BDF8]' : 'fa-robot text-[#8B5CF6]'"
|
||||
></i>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -366,44 +573,94 @@ SOFTWARE.
|
||||
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="p-4 border-t border-gray-700" x-data="{ inputValue: '', shiftPressed: false, fileName: '', isLoading: false }">
|
||||
<div class="p-4 border-t border-[#1E293B]" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }">
|
||||
<form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt" class="max-w-3xl mx-auto">
|
||||
<div class="relative w-full bg-gray-800 rounded-xl shadow-md">
|
||||
<!-- 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">
|
||||
<textarea
|
||||
id="input"
|
||||
name="input"
|
||||
x-model="inputValue"
|
||||
placeholder="Send a message..."
|
||||
class="p-4 pr-16 w-full bg-gray-800 text-gray-100 placeholder-gray-400 focus:outline-none resize-none border-0 rounded-xl transition-colors duration-200"
|
||||
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 focus:ring-2 focus:ring-[#38BDF8]/50"
|
||||
required
|
||||
@keydown.shift="shiftPressed = true"
|
||||
@keyup.shift="shiftPressed = false"
|
||||
@keydown.enter="if (!shiftPressed) { submitPrompt($event); }"
|
||||
rows="3"
|
||||
style="box-shadow: 0 0 0 1px rgba(75, 85, 99, 0.4) inset;"
|
||||
@keydown.enter.prevent="if (!shiftPressed) { submitPrompt($event); }"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<span x-text="fileName" id="fileName" class="absolute right-16 top-4 text-gray-400 text-sm mr-2"></span>
|
||||
<span x-text="fileName" id="fileName" class="absolute right-16 top-3 text-[#94A3B8] text-xs mr-2"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('input_image').click()"
|
||||
class="fa-solid fa-image text-gray-400 absolute right-12 top-4 text-lg p-2 hover:text-blue-400 transition-colors duration-200"
|
||||
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-gray-400 absolute right-20 top-4 text-lg p-2 hover:text-blue-400 transition-colors duration-200"
|
||||
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-gray-400 absolute right-28 top-4 text-lg p-2 hover:text-blue-400 transition-colors duration-200"
|
||||
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-4">
|
||||
<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"
|
||||
@@ -420,15 +677,15 @@ SOFTWARE.
|
||||
<button
|
||||
id="send-button"
|
||||
type="submit"
|
||||
class="text-lg p-2 text-gray-400 hover:text-blue-400 transition-colors duration-200"
|
||||
title="Send message"
|
||||
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}}">
|
||||
<input id="chat-model" type="hidden" value="{{.Model}}" {{ if .ContextSize }}data-context-size="{{.ContextSize}}"{{ end }}>
|
||||
<input
|
||||
id="input_image"
|
||||
type="file"
|
||||
@@ -482,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>
|
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400">{{ $galleryConfig.Description }}</p>
|
||||
<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>
|
||||
@@ -504,59 +761,9 @@ SOFTWARE.
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
<!-- Alpine store initialization -->
|
||||
<!-- Alpine store initialization and utilities -->
|
||||
<script>
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.store("chat", {
|
||||
history: [],
|
||||
languages: [undefined],
|
||||
systemPrompt: "",
|
||||
mcpMode: false,
|
||||
clear() {
|
||||
this.history.length = 0;
|
||||
},
|
||||
add(role, content, image, audio) {
|
||||
const N = this.history.length - 1;
|
||||
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)
|
||||
);
|
||||
} 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, audio });
|
||||
}
|
||||
document.getElementById('messages').scrollIntoView(false);
|
||||
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`;
|
||||
document.head.appendChild(script);
|
||||
this.languages.push(language);
|
||||
});
|
||||
},
|
||||
messages() {
|
||||
return this.history.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
image: message.image,
|
||||
audio: message.audio,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
window.copyToClipboard = (content) => {
|
||||
const tempElement = document.createElement('div');
|
||||
tempElement.innerHTML = content;
|
||||
@@ -569,6 +776,134 @@ SOFTWARE.
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -359,7 +359,7 @@
|
||||
class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="selectedModel?.description"></p>
|
||||
<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>
|
||||
@@ -669,6 +705,20 @@ 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) {
|
||||
console.error('Error rendering markdown:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
|
||||
openModal(model) {
|
||||
this.selectedModel = model;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user