feat(ui): small improvements to chat interface (#4907)

- Change chat colors
- Improve layout on small windows

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto 2025-02-26 11:10:40 +01:00 committed by GitHub
parent 61a24746a1
commit 5ad2be9c45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 263 additions and 242 deletions

View file

@ -209,11 +209,6 @@ func RegisterUIRoutes(app *fiber.App,
summary["TotalPages"] = totalPages summary["TotalPages"] = totalPages
summary["CurrentPage"] = pageNum summary["CurrentPage"] = pageNum
summary["Models"] = template.HTML(elements.ListModels(models, processingModels, galleryService)) summary["Models"] = template.HTML(elements.ListModels(models, processingModels, galleryService))
log.Debug().Msgf("totalPages : %+v\n", totalPages)
log.Debug().Msgf("prevPage : %+v\n", prevPage)
log.Debug().Msgf("nextPage : %+v\n", nextPage)
log.Debug().Msgf("CurrentPage : %+v\n", pageNum)
} }
// Render index // Render index

View file

@ -29,143 +29,207 @@ SOFTWARE.
<html lang="en"> <html lang="en">
{{template "views/partials/head" .}} {{template "views/partials/head" .}}
<script defer src="static/chat.js"></script> <script defer src="static/chat.js"></script>
<style>
body {
overflow: hidden;
}
</style>
<body class="bg-gray-900 text-gray-200" x-data="{ key: $store.chat.key }">
<div class="flex flex-col min-h-screen">
<!-- Adjust layout classes to let the chat fill the viewport and be responsive -->
<body class="bg-slate-900 text-gray-100 flex flex-col h-screen" x-data="{ key: $store.chat.key }">
{{template "views/partials/navbar" .}} {{template "views/partials/navbar" .}}
<div class="chat-container mt-2 mr-2 ml-2 mb-2 bg-gray-800 shadow-lg rounded-lg" >
<!-- Chat Header -->
<div class="border-b border-gray-700 p-4" x-data="{ component: 'menu' }">
<div class="flex items-center justify-between"> <!-- Container fills available space (flex-1) and allows scrolling if needed -->
<div class="flex flex-col flex-1 overflow-hidden">
<h1 class="text-lg font-semibold"> <i class="fa-solid fa-comments"></i> Chat {{ if .Model }} with {{.Model}} {{ end }} <a href="browse?term={{.Model}}" ><i class="fas fa-brain pr-2"></i></a> <a href="https://localai.io/features/text-generation/" target="_blank" > <!-- Header can stay at the top -->
<i class="fas fa-circle-info pr-2"></i> <div class="border-b border-gray-700 p-4" x-data="{ component: 'menu' }">
</a></h1> <div class="flex items-center justify-between flex-wrap gap-2">
<div x-show="component === 'menu'" id="menu"> <h1 class="text-lg font-semibold flex items-center">
<button <i class="fa-solid fa-comments mr-2"></i>
@click="$store.chat.clear()" Chat
id="clear" {{ if .Model }} with {{.Model}} {{ end }}
title="Clear chat history" <a class="ml-2" href="browse?term={{.Model}}">
<i class="fas fa-brain pr-2"></i>
</a>
<a href="https://localai.io/features/text-generation/" target="_blank">
<i class="fas fa-circle-info"></i>
</a>
</h1>
<div x-show="component === 'menu'" id="menu">
<button
@click="$store.chat.clear()"
id="clear"
title="Clear chat history"
data-twe-ripple-init
data-twe-ripple-color="light"
class="m-2 inline-block rounded bg-primary px-4 py-1 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 focus:bg-primary-accent-300 focus:outline-none"
>
Clear chat 🔥
</button>
<button
@click="component = 'key'"
title="Update API key"
class="m-2 inline-block rounded bg-primary px-4 py-1 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 focus:bg-primary-accent-300 focus:outline-none"
>
Set API Key🔑
</button>
<button
@click="component = 'system_prompt'"
title="System Prompt"
class="m-2 inline-block rounded bg-primary px-4 py-1 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 focus:bg-primary-accent-300 focus:outline-none"
>
Set system prompt
</button>
</div>
<!-- API Key form -->
<form x-show="component === 'key'" id="key" class="flex items-center gap-2">
<input
type="password"
id="apiKey"
name="apiKey"
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"
placeholder="OpenAI API Key"
x-model.lazy="key"
/>
<button
@click="component = 'menu'"
type="submit"
title="Save API key"
class="text-white"
>
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
<!-- System Prompt form -->
<form x-show="component === 'system_prompt'" id="system_prompt" class="flex flex-col gap-2 mt-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"
placeholder="System prompt"
x-model.lazy="system_prompt"
></textarea>
<button
@click="component = 'menu'"
type="submit"
title="Save Prompt"
class="self-end text-white"
>
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
data-twe-ripple-init <!-- Model selection -->
data-twe-ripple-color="light" <select
class="m-2 float-right inline-block rounded bg-primary px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" x-data="{ link : '' }"
x-model="link"
x-init="$watch('link', value => window.location = link)"
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"
> >
Clear chat 🔥 <option value="" disabled class="text-gray-400">Select a model</option>
</button> {{ $model:=.Model}}
<button @click="component = 'key'" title="Update API key" {{ range .ModelsConfig }}
class="m-2 float-right inline-block rounded bg-primary px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" {{ $cfg := . }}
>Set API Key🔑</button> {{ range .KnownUsecaseStrings }}
<button @click="component = 'system_prompt'" title="System Prompt" {{ if eq . "FLAG_CHAT" }}
class="m-2 float-right inline-block rounded bg-primary px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" <option
>Set system prompt</button> value="chat/{{$cfg.Name}}"
</div> {{ if eq $cfg.Name $model }} selected {{end}}
<form x-show="component === 'key'" id="key"> class="bg-gray-700 text-white"
<input >
type="password" {{$cfg.Name}}
id="apiKey" </option>
name="apiKey" {{ end }}
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" {{ end }}
placeholder="OpenAI API Key"
x-model.lazy="key"
/>
<button @click="component = 'menu'" type="submit" title="Save API key">
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
<form x-show="component === 'system_prompt'" id="system_prompt">
<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"
placeholder="System prompt"
x-model.lazy="system_prompt"
></textarea>
<button @click="component = 'menu'" type="submit" title="Save Prompt">
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
<select x-data="{ link : '' }" x-model="link" x-init="$watch('link', value => window.location = link)"
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"
>
<!-- Options -->
<option value="" disabled class="text-gray-400" >Select a model</option>
{{ $model:=.Model}}
{{ range .ModelsConfig }}
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_CHAT" }}
<option value="chat/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-gray-700 text-white">{{$cfg.Name}}</option>
{{ end }} {{ end }}
{{ end }} {{ range .ModelsWithoutConfig }}
{{ end }} <option
{{ range .ModelsWithoutConfig }} value="chat/{{.}}"
<option value="chat/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-gray-700 text-white">{{.}}</option> {{ if eq . $model }} selected {{ end }}
{{end}} class="bg-gray-700 text-white"
</select> >
</div> {{.}}
</div> </option>
{{end}}
<div class="chat-messages p-4" id="chat" x-data="{history: $store.chat.history}"> </select>
<p id="usage" x-show="history.length === 0">
Start chatting with the AI by typing a prompt in the input field below and pressing Enter.
For models that support images, you can upload an image by clicking the paperclip <i class="fa-solid fa-paperclip"></i> icon.
</p>
<div id="messages">
<template x-for="message in history">
<div class="message flex items-start space-x-2 my-2" >
<!--<img :src="message.role === 'user' ? '/path/to/user-icon.png' : '/path/to/bot-icon.png'" alt="" class="h-6 w-6">-->
<i class="fa-solid h-8 w-8" :class="message.role === 'user' ? 'fa-user' : 'fa-robot'" ></i>
<div class="flex flex-col flex-1">
<span class="text-xs font-semibold text-gray-600" x-text="message.role === 'user' ? 'User' : 'Assistant ({{.Model}})'"></span>
<template x-if="message.role === 'user'">
<div class="p-2 flex-1 rounded" :class="message.role" x-html="message.html"></div>
</template>
<template x-if="message.role === 'assistant'">
<div class="p-2 flex-1 rounded" :class="message.role" x-html="message.html"></div>
</template>
<template x-if="message.image">
<img :src="message.image" alt="Image" class="rounded-lg mt-2 h-36 w-36">
</template>
</div>
</div> </div>
</template> </div>
<!-- Main chat area (flex-1 for expansion) -->
<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">
Start chatting with the AI by typing a prompt in the input field below and pressing Enter.
For models that support images, you can upload an image by clicking the paperclip
<i class="fa-solid fa-paperclip"></i> icon.
</p>
<div id="messages">
<template x-for="message in history">
<div class="message flex items-start space-x-2 my-2">
<i
class="fa-solid h-8 w-8"
:class="message.role === 'user' ? 'fa-user' : 'fa-robot'"
></i>
<div class="flex flex-col flex-1">
<span
class="text-xs font-semibold text-gray-400"
x-text="message.role === 'user' ? 'User' : 'Assistant ({{.Model}})'"
></span>
<template x-if="message.role === 'user'">
<div class="p-2 flex-1 rounded bg-gray-700 text-white" x-html="message.html"></div>
</template>
<template x-if="message.role === 'assistant'">
<div class="p-2 flex-1 rounded bg-gray-700 text-white" x-html="message.html"></div>
</template>
<template x-if="message.image">
<img :src="message.image" alt="Image" class="rounded-lg mt-2 max-w-xs">
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Chat Input -->
<div class="p-4 border-t border-gray-700" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }">
<div id="loader" class="my-2 loader" style="display: none;"></div>
<input id="chat-model" type="hidden" value="{{.Model}}">
<input
id="input_image"
type="file"
style="display: none;"
@change="fileName = $event.target.files[0].name"
/>
<form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt">
<div class="relative w-full">
<textarea
id="input"
name="input"
x-model="inputValue"
placeholder="Send a message..."
class="p-2 pr-16 border rounded w-full bg-gray-600 text-gray-100 placeholder-gray-300 focus:outline-none resize-none"
required
@keydown.shift="shiftPressed = true"
@keyup.shift="shiftPressed = false"
@keydown.enter="if (!shiftPressed) { submitPrompt($event); }"
rows="3"
></textarea>
<span x-text="fileName" id="fileName" class="absolute right-16 top-3 text-gray-300 text-sm mr-2"></span>
<button
type="button"
onclick="document.getElementById('input_image').click()"
class="fa-solid fa-paperclip text-gray-300 absolute right-10 top-3 text-lg p-2 hover:text-gray-100"
title="Attach an image"
></button>
<button
type="submit"
class="absolute right-2 top-3 text-lg p-2 text-gray-300 hover:text-gray-100"
title="Send message"
>
<i class="fa-solid fa-circle-up"></i>
</button>
</div>
</form>
</div> </div>
</div> </div>
<div class="p-4 border-t border-gray-700" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }"> <!-- Alpine store initialization -->
<div id="loader" class="my-2 loader" style="display: none;"></div>
<input id="chat-model" type="hidden" value="{{.Model}}">
<input id="input_image" type="file" style="display: none;" @change="fileName = $event.target.files[0].name">
<form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt">
<div class="relative w-full">
<textarea
id="input"
name="input"
x-model="inputValue"
placeholder="Send a message..."
class="p-2 pl-2 border rounded w-full bg-gray-600 text-white placeholder-gray-300"
required
@keydown.shift="shiftPressed = true"
@keyup.shift="shiftPressed = false"
@keydown.enter="if (!shiftPressed) { submitPrompt($event); }"
style="padding-right: 4rem;"
></textarea>
<span x-text="fileName" id="fileName" class="absolute right-16 top-5 text-gray-300 text-sm mr-2"></span>
<button type="button" onclick="document.getElementById('input_image').click()" class="fa-solid fa-paperclip text-gray-300 ml-2 absolute right-10 top-3 text-lg p-2">
</button>
<button type=submit><i class="fa-solid fa-circle-up text-gray-300 absolute right-2 top-3 text-lg p-2"></i></button>
</div>
</form>
</div>
<script> <script>
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.store("chat", { Alpine.store("chat", {
@ -178,31 +242,21 @@ SOFTWARE.
const N = this.history.length - 1; const N = this.history.length - 1;
if (this.history.length && this.history[N].role === role) { if (this.history.length && this.history[N].role === role) {
this.history[N].content += content; this.history[N].content += content;
str = this.history[N].content;
this.history[N].html = DOMPurify.sanitize( this.history[N].html = DOMPurify.sanitize(
marked.parse(this.history[N].content), marked.parse(this.history[N].content)
); );
} else { } else {
c = "" let c = "";
// split content newlines in content
const lines = content.split("\n"); const lines = content.split("\n");
// for each line, do DOMPurify.sanitize(marked.parse(line)) and add it to c
lines.forEach((line) => { lines.forEach((line) => {
c += DOMPurify.sanitize(marked.parse(line)); c += DOMPurify.sanitize(marked.parse(line));
}); });
this.history.push({ role, content, html: c, image });
this.history.push({
role: role,
content: content,
html: c,
image: image,
});
} }
const parser = new DOMParser(); const parser = new DOMParser();
const html = parser.parseFromString( const html = parser.parseFromString(
this.history[this.history.length - 1].html, this.history[this.history.length - 1].html,
"text/html", "text/html"
); );
const code = html.querySelectorAll("pre code"); const code = html.querySelectorAll("pre code");
if (!code.length) return; if (!code.length) return;
@ -216,17 +270,14 @@ SOFTWARE.
}); });
}, },
messages() { messages() {
return this.history.map((message) => { return this.history.map((message) => ({
return { role: message.role,
role: message.role, content: message.content,
content: message.content, image: message.image,
image: message.image, }));
};
});
}, },
}); });
}); });
</script> </script>
</div>
</body> </body>
</html> </html>

View file

@ -1,36 +1,20 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title> <title>{{.Title}}</title>
<base href="{{.BaseURL}}" /> <base href="{{.BaseURL}}" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<link <link rel="stylesheet" href="static/assets/highlightjs.css" />
rel="stylesheet"
href="static/assets/highlightjs.css"
/>
<script defer src="static/assets/highlightjs.js"></script> <script defer src="static/assets/highlightjs.js"></script>
<script <script defer src="static/assets/alpine.js"></script>
defer <script defer src="static/assets/marked.js"></script>
src="static/assets/alpine.js" <script defer src="static/assets/purify.js"></script>
></script>
<script
defer
src="static/assets/marked.js"
></script>
<script
defer
src="static/assets/purify.js"
></script>
<link href="static/general.css" rel="stylesheet" /> <link href="static/general.css" rel="stylesheet" />
<link href="static/assets/font1.css" rel="stylesheet"> <link href="static/assets/font1.css" rel="stylesheet">
<link <link href="static/assets/font2.css" rel="stylesheet" />
href="static/assets/font2.css" <link rel="stylesheet" href="static/assets/tw-elements.css" />
rel="stylesheet" />
<link
rel="stylesheet"
href="static/assets/tw-elements.css" />
<script src="static/assets/tailwindcss.js"></script> <script src="static/assets/tailwindcss.js"></script>
<script> <script>
tailwind.config = { tailwind.config = {
darkMode: "class", darkMode: "class",
@ -48,84 +32,75 @@
function copyClipboard(token) { function copyClipboard(token) {
navigator.clipboard.writeText(token) navigator.clipboard.writeText(token)
.then(() => { .then(() => {
console.log('Text copied to clipboard:', token); console.log('Text copied to clipboard:', token);
alert('Text copied to clipboard!'); alert('Text copied to clipboard!');
}) })
.catch(err => { .catch(err => {
console.error('Failed to copy token:', err); console.error('Failed to copy token:', err);
}); });
} }
</script> </script>
<link href="static/assets/fontawesome/css/fontawesome.css" rel="stylesheet" /> <link href="static/assets/fontawesome/css/fontawesome.css" rel="stylesheet" />
<link href="static/assets/fontawesome/css/brands.css" rel="stylesheet" /> <link href="static/assets/fontawesome/css/brands.css" rel="stylesheet" />
<link href="static/assets/fontawesome/css/solid.css" rel="stylesheet" /> <link href="static/assets/fontawesome/css/solid.css" rel="stylesheet" />
<script src="static/assets/flowbite.min.js"></script> <script src="static/assets/flowbite.min.js"></script>
<script src="static/assets/htmx.js" crossorigin="anonymous"></script> <script src="static/assets/htmx.js" crossorigin="anonymous"></script>
<!-- P2P Animation START -->
<!-- Example responsive styling improvements -->
<style> <style>
.animation-container { .animation-container {
position: relative; position: relative;
width: 100%; width: 100%;
height: 25vh; height: 25vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
} }
canvas { canvas {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
} }
.text-overlay { .text-overlay {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
text-align: center; text-align: center;
z-index: 1; z-index: 1;
}
.fa-circle-nodes {
animation: rotateCircleNodes 8s linear infinite;
display: inline-block;
}
@keyframes rotateCircleNodes {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fa-flask {
animation: shakeFlask 3s ease-in-out infinite;
transform-origin: bottom center;
}
@keyframes shakeFlask {
0%, 10% { transform: rotate(0deg); }
20% { transform: rotate(-10deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(8deg); }
60% { transform: rotate(-5deg); }
70% { transform: rotate(5deg); }
80% { transform: rotate(-2deg); }
90% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
} }
</style> </style>
<!-- P2P Animation END -->
<!-- Flask and node animation -->
<style>
.fa-circle-nodes {
/* font-size: 100px; /* Adjust the size as needed */
animation: rotateCircleNodes 8s linear infinite; /* Slow and fluid rotation */
display: inline-block;
}
@keyframes rotateCircleNodes { <!-- Initialize Flowbite on HTMX content load -->
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Animation for the warning box */
.fa-flask {
/* font-size: 100px; /* Adjust the size as needed */
animation: shakeFlask 3s ease-in-out infinite; /* Smooth easing and longer duration for fluidity */
transform-origin: bottom center;
}
@keyframes shakeFlask {
0%, 10% { transform: rotate(0deg); } /* Start and end still */
20% { transform: rotate(-10deg); } /* Smooth transition to left */
30% { transform: rotate(10deg); } /* Smooth transition to right */
40% { transform: rotate(-8deg); } /* Smooth transition to left */
50% { transform: rotate(8deg); } /* Smooth transition to right */
60% { transform: rotate(-5deg); } /* Smooth transition to left */
70% { transform: rotate(5deg); } /* Smooth transition to right */
80% { transform: rotate(-2deg); } /* Smooth transition to left */
90% { transform: rotate(2deg); } /* Smooth transition to right */
100% { transform: rotate(0deg); } /* Return to center */
}
</style>
<!-- https://stackoverflow.com/questions/76051980/flowbite-component-not-working-when-loaded-via-htmx-django-project -->
<script> <script>
htmx.onLoad(function(content) { htmx.onLoad(function(content) {
initFlowbite(); initFlowbite();
}) });
</script> </script>
</head> </head>