LocalAI/core/http/views/chat.html
Ettore Di Giacinto 773b13321c feat(ui): show more informations in the chat view, minor adjustments to model gallery
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-02-26 18:20:23 +01:00

349 lines
No EOL
16 KiB
HTML

<!--
Part of this page is based on the OpenAI Chatbot example by David Härer:
https://github.com/david-haerer/chatapi
MIT License Copyright (c) 2023 David Härer
Copyright (c) 2024 Ettore Di Giacinto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<!doctype html>
<html lang="en">
{{template "views/partials/head" .}}
<script defer src="static/chat.js"></script>
{{ $allGalleryConfigs:=.GalleryConfig }}
{{ $model:=.Model}}
<!-- 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" .}}
<!-- Container fills available space (flex-1) and allows scrolling if needed -->
<div class="flex flex-col flex-1 overflow-hidden">
<!-- Header can stay at the top -->
<div class="border-b border-gray-700 p-4" x-data="{ component: 'menu' }">
<div class="flex items-center justify-between flex-wrap gap-2">
<h1 class="text-lg font-semibold flex items-center">
<i class="fa-solid fa-comments mr-2"></i>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
<img src="{{$galleryConfig.Icon}}" class="rounded-lg mt-2 max-w-8 max-h-8 mr-2">
<button data-twe-ripple-init data-twe-ripple-color="light" class="float-left 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" data-modal-target="modal" data-modal-toggle="modal">
<p class="flex items-center">
<i class="fas fa-info-circle pr-2"></i>
Info
</p>
</button>
<div id="modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Body -->
<div class="p-4 md:p-5 space-y-4">
<div class="flex justify-center items-center">
<img class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" src="{{$galleryConfig.Icon}}" loading="lazy"/>
</div>
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400">{{ $galleryConfig.Description }}</p>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul>
{{range $galleryConfig.URLs}}
<li><a href="{{ . }}">{{ . }}</a></li>
{{end}}
</ul>
</div>
<!-- Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
Chat
{{ if .Model }} with {{.Model}} {{ end }}
<a class="ml-2" href="browse?term={{.Model}}">
<i class="fas fa-brain pr-2"></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>
<a href="https://localai.io/features/text-generation/" target="_blank">
Docs <i class="fas fa-circle-info"></i>
</a>
</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>
<!-- Model selection -->
<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"
>
<option value="" disabled class="text-gray-400">Select a model</option>
{{ range .ModelsConfig }}
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_CHAT" }}
<option
value="chat/{{$cfg.Name}}"
{{ if eq $cfg.Name $model }} selected {{end}}
class="bg-gray-700 text-white"
>
{{$cfg.Name}}
</option>
{{ end }}
{{ end }}
{{ end }}
{{ range .ModelsWithoutConfig }}
<option
value="chat/{{.}}"
{{ if eq . $model }} selected {{ end }}
class="bg-gray-700 text-white"
>
{{.}}
</option>
{{end}}
</select>
</div>
</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">
{{ if .Model }}
{{ $galleryConfig:= index $allGalleryConfigs .Model}}
<template x-if="message.role === 'user'">
<i
class="fa-solid h-8 w-8 fa-user"
></i>
</template>
<template x-if="message.role != 'user'">
<img src="{{$galleryConfig.Icon}}" class="rounded-lg mt-2 max-w-8 max-h-8">
</template>
{{ else }}
<i
class="fa-solid h-8 w-8"
:class="message.role === 'user' ? 'fa-user' : 'fa-robot'"
></i>
{{ end }}
<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>
<!-- Alpine store initialization -->
<script>
document.addEventListener("alpine:init", () => {
Alpine.store("chat", {
history: [],
languages: [undefined],
clear() {
this.history.length = 0;
},
add(role, content, image) {
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 });
}
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,
}));
},
});
});
</script>
</body>
</html>