feat(ui): improve chat interface (#4910)
Some checks are pending
Explorer deployment / build-linux (push) Waiting to run
GPU tests / ubuntu-latest (1.21.x) (push) Waiting to run
generate and publish intel docker caches / generate_caches (intel/oneapi-basekit:2025.0.0-0-devel-ubuntu22.04, linux/amd64, ubuntu-latest) (push) Waiting to run
build container images / hipblas-jobs (-aio-gpu-hipblas, rocm/dev-ubuntu-22.04:6.1, hipblas, true, ubuntu:22.04, extras, latest-gpu-hipblas, latest-aio-gpu-hipblas, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -hipblas-ffmpeg) (push) Waiting to run
build container images / hipblas-jobs (rocm/dev-ubuntu-22.04:6.1, hipblas, false, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -hipblas-core) (push) Waiting to run
build container images / hipblas-jobs (rocm/dev-ubuntu-22.04:6.1, hipblas, false, ubuntu:22.04, extras, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -hipblas) (push) Waiting to run
build container images / hipblas-jobs (rocm/dev-ubuntu-22.04:6.1, hipblas, true, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -hipblas-ffmpeg-core) (push) Waiting to run
build container images / self-hosted-jobs (-aio-gpu-intel-f16, quay.io/go-skynet/intel-oneapi-base:latest, sycl_f16, true, ubuntu:22.04, extras, latest-gpu-intel-f16, latest-aio-gpu-intel-f16, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -sycl-f16-ffmpeg) (push) Waiting to run
build container images / self-hosted-jobs (-aio-gpu-intel-f32, quay.io/go-skynet/intel-oneapi-base:latest, sycl_f32, true, ubuntu:22.04, extras, latest-gpu-intel-f32, latest-aio-gpu-intel-f32, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -sycl-f32-ffmpeg) (push) Waiting to run
build container images / self-hosted-jobs (-aio-gpu-nvidia-cuda-11, ubuntu:22.04, cublas, 11, 7, true, extras, latest-gpu-nvidia-cuda-11, latest-aio-gpu-nvidia-cuda-11, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -cublas-cuda11-ffmpeg) (push) Waiting to run
build container images / self-hosted-jobs (-aio-gpu-nvidia-cuda-12, ubuntu:22.04, cublas, 12, 0, true, extras, latest-gpu-nvidia-cuda-12, latest-aio-gpu-nvidia-cuda-12, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -cublas-cuda12-ffmpeg) (push) Waiting to run
build container images / self-hosted-jobs (quay.io/go-skynet/intel-oneapi-base:latest, sycl_f16, false, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -sycl-f16-core) (push) Waiting to run
build container images / self-hosted-jobs (quay.io/go-skynet/intel-oneapi-base:latest, sycl_f16, true, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -sycl-f16-ffmpeg-core) (push) Waiting to run
build container images / self-hosted-jobs (quay.io/go-skynet/intel-oneapi-base:latest, sycl_f32, false, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -sycl-f32-core) (push) Waiting to run
build container images / self-hosted-jobs (quay.io/go-skynet/intel-oneapi-base:latest, sycl_f32, true, ubuntu:22.04, core, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -sycl-f32-ffmpeg-core) (push) Waiting to run
build container images / self-hosted-jobs (ubuntu:22.04, , , extras, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, ) (push) Waiting to run
build container images / self-hosted-jobs (ubuntu:22.04, , true, extras, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, auto, -ffmpeg) (push) Waiting to run
build container images / self-hosted-jobs (ubuntu:22.04, cublas, 11, 7, , extras, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -cublas-cuda11) (push) Waiting to run
build container images / self-hosted-jobs (ubuntu:22.04, cublas, 12, 0, , extras, --jobs=3 --output-sync=target, linux/amd64, arc-runner-set, false, -cublas-cuda12) (push) Waiting to run
build container images / core-image-build (-aio-cpu, ubuntu:22.04, , true, core, latest-cpu, latest-aio-cpu, --jobs=4 --output-sync=target, linux/amd64,linux/arm64, arc-runner-set, false, auto, -ffmpeg-core) (push) Waiting to run
build container images / core-image-build (ubuntu:22.04, cublas, 11, 7, , core, --jobs=4 --output-sync=target, linux/amd64, arc-runner-set, false, false, -cublas-cuda11-core) (push) Waiting to run
build container images / core-image-build (ubuntu:22.04, cublas, 11, 7, true, core, --jobs=4 --output-sync=target, linux/amd64, arc-runner-set, false, false, -cublas-cuda11-ffmpeg-core) (push) Waiting to run
build container images / core-image-build (ubuntu:22.04, cublas, 12, 0, , core, --jobs=4 --output-sync=target, linux/amd64, arc-runner-set, false, false, -cublas-cuda12-core) (push) Waiting to run
build container images / core-image-build (ubuntu:22.04, cublas, 12, 0, true, core, --jobs=4 --output-sync=target, linux/amd64, arc-runner-set, false, false, -cublas-cuda12-ffmpeg-core) (push) Waiting to run
build container images / core-image-build (ubuntu:22.04, vulkan, true, core, latest-vulkan-ffmpeg-core, --jobs=4 --output-sync=target, linux/amd64, arc-runner-set, false, false, -vulkan-ffmpeg-core) (push) Waiting to run
build container images / gh-runner (nvcr.io/nvidia/l4t-jetpack:r36.4.0, cublas, 12, 0, true, core, latest-nvidia-l4t-arm64-core, --jobs=4 --output-sync=target, linux/arm64, ubuntu-24.04-arm, true, false, -nvidia-l4t-arm64-core) (push) Waiting to run
Security Scan / tests (push) Waiting to run
Tests extras backends / tests-transformers (push) Waiting to run
Tests extras backends / tests-rerankers (push) Waiting to run
Tests extras backends / tests-diffusers (push) Waiting to run
Tests extras backends / tests-coqui (push) Waiting to run
tests / tests-linux (1.21.x) (push) Waiting to run
tests / tests-aio-container (push) Waiting to run
tests / tests-apple (1.21.x) (push) Waiting to run

* feat(ui): show more informations in the chat view, minor adjustments to model gallery

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): UI improvements

Visual improvements and bugfixes including:
- disable pagination during search
- fix scrolling on new message

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto 2025-02-26 18:27:18 +01:00 committed by GitHub
parent 5ad2be9c45
commit c87870b18e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 421 additions and 204 deletions

View File

@ -29,6 +29,8 @@ func InstallModelFromGallery(galleries []config.Gallery, name string, basePath s
if err != nil { if err != nil {
return err return err
} }
config.Description = model.Description
config.License = model.License
} else if len(model.ConfigFile) > 0 { } else if len(model.ConfigFile) > 0 {
// TODO: is this worse than using the override method with a blank cfg yaml? // TODO: is this worse than using the override method with a blank cfg yaml?
reYamlConfig, err := yaml.Marshal(model.ConfigFile) reYamlConfig, err := yaml.Marshal(model.ConfigFile)

View File

@ -404,6 +404,15 @@ func RegisterUIRoutes(app *fiber.App,
return c.Redirect(utils.BaseURL(c)) return c.Redirect(utils.BaseURL(c))
} }
modelThatCanBeUsed := "" modelThatCanBeUsed := ""
galleryConfigs := map[string]*gallery.Config{}
for _, m := range backendConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
}
title := "LocalAI - Chat" title := "LocalAI - Chat"
@ -419,6 +428,7 @@ func RegisterUIRoutes(app *fiber.App,
"Title": title, "Title": title,
"BaseURL": utils.BaseURL(c), "BaseURL": utils.BaseURL(c),
"ModelsWithoutConfig": modelsWithoutConfig, "ModelsWithoutConfig": modelsWithoutConfig,
"GalleryConfig": galleryConfigs,
"ModelsConfig": backendConfigs, "ModelsConfig": backendConfigs,
"Model": modelThatCanBeUsed, "Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(), "Version": internal.PrintableVersion(),
@ -434,10 +444,21 @@ func RegisterUIRoutes(app *fiber.App,
backendConfigs := cl.GetAllBackendConfigs() backendConfigs := cl.GetAllBackendConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
galleryConfigs := map[string]*gallery.Config{}
for _, m := range backendConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
}
summary := fiber.Map{ summary := fiber.Map{
"Title": "LocalAI - Chat with " + c.Params("model"), "Title": "LocalAI - Chat with " + c.Params("model"),
"BaseURL": utils.BaseURL(c), "BaseURL": utils.BaseURL(c),
"ModelsConfig": backendConfigs, "ModelsConfig": backendConfigs,
"GalleryConfig": galleryConfigs,
"ModelsWithoutConfig": modelsWithoutConfig, "ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Params("model"), "Model": c.Params("model"),
"Version": internal.PrintableVersion(), "Version": internal.PrintableVersion(),

View File

@ -49,7 +49,7 @@ function submitPrompt(event) {
document.getElementById("input").value = ""; document.getElementById("input").value = "";
const key = localStorage.getItem("key"); const key = localStorage.getItem("key");
const systemPrompt = localStorage.getItem("system_prompt"); const systemPrompt = localStorage.getItem("system_prompt");
Alpine.nextTick(() => { document.getElementById('messages').scrollIntoView(false); });
promptGPT(systemPrompt, key, input); promptGPT(systemPrompt, key, input);
} }
@ -74,7 +74,6 @@ function readInputImage() {
// Make the "loader" visible // Make the "loader" visible
document.getElementById("loader").style.display = "block"; document.getElementById("loader").style.display = "block";
document.getElementById("input").disabled = true; document.getElementById("input").disabled = true;
document.getElementById('messages').scrollIntoView(false)
messages = Alpine.store("chat").messages(); messages = Alpine.store("chat").messages();
@ -181,8 +180,8 @@ function readInputImage() {
const chatStore = Alpine.store("chat"); const chatStore = Alpine.store("chat");
chatStore.add("assistant", token); chatStore.add("assistant", token);
// Efficiently scroll into view without triggering multiple reflows // Efficiently scroll into view without triggering multiple reflows
const messages = document.getElementById('messages'); // const messages = document.getElementById('messages');
messages.scrollTop = messages.scrollHeight; // messages.scrollTop = messages.scrollHeight;
}; };
let buffer = ""; let buffer = "";

View File

@ -4,7 +4,7 @@ Part of this page is based on the OpenAI Chatbot example by David Härer:
https://github.com/david-haerer/chatapi https://github.com/david-haerer/chatapi
MIT License Copyright (c) 2023 David Härer MIT License Copyright (c) 2023 David Härer
Copyright (c) 2024 Ettore Di Giacinto Copyright (c) 2024-2025 Ettore Di Giacinto
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -29,102 +29,39 @@ 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>
{{ $allGalleryConfigs:=.GalleryConfig }}
<!-- Adjust layout classes to let the chat fill the viewport and be responsive --> {{ $model:=.Model}}
<body class="bg-slate-900 text-gray-100 flex flex-col h-screen" x-data="{ key: $store.chat.key }"> <body class="bg-slate-900 text-gray-100 flex flex-col h-screen" x-data="{ key: $store.chat.key, sidebarOpen: true }">
{{template "views/partials/navbar" .}} {{template "views/partials/navbar" .}}
<!-- Container fills available space (flex-1) and allows scrolling if needed --> <!-- Main container with sidebar toggle -->
<div class="flex flex-col flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden relative">
<!-- Sidebar -->
<div
class="sidebar bg-gray-800 fixed top-16 bottom-0 left-0 w-64 transform transition-transform duration-300 ease-in-out z-30 border-r border-gray-700 overflow-y-auto"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<!-- Header can stay at the top --> <div class="p-4 flex justify-between items-center border-b border-gray-700">
<div class="border-b border-gray-700 p-4" x-data="{ component: 'menu' }"> <h2 class="text-lg font-semibold">Chat Settings</h2>
<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>
Chat
{{ if .Model }} with {{.Model}} {{ end }}
<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 <button
@click="$store.chat.clear()" @click="sidebarOpen = false"
id="clear" class="text-gray-400 hover:text-white focus:outline-none">
title="Clear chat history" <i class="fa-solid fa-times"></i>
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> </button>
</div> </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 --> <!-- Sidebar content -->
<div class="p-4 space-y-6">
<!-- Model selection - Fixed to properly select current model -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-300">Select Model</label>
<select <select
x-data="{ link : '' }" id="modelSelector"
x-model="link" class="w-full bg-gray-700 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"
x-init="$watch('link', value => window.location = link)" onchange="window.location = this.value"
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> <option value="" disabled class="text-gray-400">Select a model</option>
{{ $model:=.Model}}
{{ range .ModelsConfig }} {{ range .ModelsConfig }}
{{ $cfg := . }} {{ $cfg := . }}
{{ range .KnownUsecaseStrings }} {{ range .KnownUsecaseStrings }}
@ -150,41 +87,207 @@ SOFTWARE.
{{end}} {{end}}
</select> </select>
</div> </div>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<!-- Model info -->
<div class="space-y-2">
<div class="flex items-center">
<img src="{{$galleryConfig.Icon}}" class="rounded-lg w-8 h-8 mr-2">
<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-white bg-gray-700 hover:bg-gray-600 transition-colors" data-modal-target="model-info-modal" data-modal-toggle="model-info-modal">
<i class="fas fa-info-circle mr-2"></i>
Model Information
</button>
</div>
{{ end }}
{{ end }}
<div x-data="{ activeTab: 'actions' }" class="space-y-4">
<!-- Tab navigation -->
<div class="flex border-b border-gray-700">
<button
@click="activeTab = 'actions'"
:class="activeTab === 'actions' ? 'border-b-2 border-blue-500 text-white' : 'text-gray-400 hover:text-white'"
class="py-2 px-4 text-sm font-medium">
Actions
</button>
<button
@click="activeTab = 'settings'"
:class="activeTab === 'settings' ? 'border-b-2 border-blue-500 text-white' : 'text-gray-400 hover:text-white'"
class="py-2 px-4 text-sm font-medium">
Settings
</button>
</div> </div>
<!-- Main chat area (flex-1 for expansion) --> <!-- 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-white bg-gray-700 hover:bg-gray-600 transition-colors"
>
<i class="fa-solid fa-trash-can mr-2"></i> Clear chat
</button>
<a
href="https://localai.io/features/text-generation/"
target="_blank"
class="w-full flex items-center px-3 py-2 text-sm rounded text-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>
<!-- Settings tab -->
<div x-show="activeTab === 'settings'" x-data="{ showKeyForm: false, showPromptForm: false }" class="space-y-3">
<button
@click="showKeyForm = !showKeyForm; showPromptForm = false"
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"
>
<span><i class="fa-solid fa-key mr-2"></i> API Key</span>
<i :class="showKeyForm ? 'fa-chevron-up' : 'fa-chevron-down'" class="fa-solid"></i>
</button>
<div x-show="showKeyForm" class="p-3 bg-gray-700 rounded">
<form id="key" class="flex flex-col space-y-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
type="submit"
class="px-3 py-2 text-sm rounded text-white bg-blue-600 hover:bg-blue-700 transition-colors"
>
Save API Key
</button>
</form>
</div>
<button
@click="showPromptForm = !showPromptForm; showKeyForm = false"
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"
>
<span><i class="fa-solid fa-message mr-2"></i> System Prompt</span>
<i :class="showPromptForm ? 'fa-chevron-up' : 'fa-chevron-down'" class="fa-solid"></i>
</button>
<div x-show="showPromptForm" class="p-3 bg-gray-700 rounded">
<form id="system_prompt" 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"
placeholder="System prompt"
x-model.lazy="$store.chat.systemPrompt"
></textarea>
<button
type="submit"
class="px-3 py-2 text-sm rounded text-white bg-blue-600 hover:bg-blue-700 transition-colors"
>
Save System Prompt
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Main chat container (shifts with sidebar) -->
<div
class="flex-1 flex flex-col transition-all duration-300 ease-in-out"
:class="sidebarOpen ? 'ml-64' : 'ml-0'">
<!-- Chat header with toggle button -->
<div class="border-b border-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="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 w-8 h-8 mr-2">
{{ end }}
<h1 class="text-lg font-semibold">
Chat {{ if .Model }} with {{.Model}} {{ end }}
</h1>
</div>
</div>
<!-- Chat messages area -->
<div class="flex-1 p-4 overflow-auto" id="chat" x-data="{history: $store.chat.history}"> <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-gray-300">
Start chatting with the AI by typing a prompt in the input field below and pressing Enter. 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 For models that support images, you can upload an image by clicking the paperclip
<i class="fa-solid fa-paperclip"></i> icon. <i class="fa-solid fa-paperclip"></i> icon.
</p> </p>
<div id="messages"> <div id="messages" class="max-w-3xl mx-auto">
<template x-for="message in history"> <template x-for="message in history">
<div class="message flex items-start space-x-2 my-2"> <div :class="message.role === 'user' ? 'flex items-start space-x-2 my-2 justify-end' : 'flex items-start space-x-2 my-2'">
<i {{ if .Model }}
class="fa-solid h-8 w-8" {{ $galleryConfig:= index $allGalleryConfigs .Model}}
: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'"> <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> <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"> <template x-if="message.image">
<img :src="message.image" alt="Image" class="rounded-lg mt-2 max-w-xs"> <img :src="message.image" alt="Image" class="rounded-lg mt-2 max-w-xs">
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<template x-if="message.role != 'user'">
<div class="flex items-center space-x-2">
<img src="{{$galleryConfig.Icon}}" class="rounded-lg mt-2 max-w-8 max-h-8">
<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">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<template x-if="message.image">
<img :src="message.image" alt="Image" class="rounded-lg mt-2 max-w-xs">
</template>
</div> </div>
</div> </div>
</template>
{{ else }}
<i
class="fa-solid h-8 w-8"
:class="message.role === 'user' ? 'fa-user' : 'fa-robot'"
></i>
{{ end }}
</div>
</template>
</div>
</div>
<!-- Chat Input --> <!-- Chat Input -->
<div class="p-4 border-t border-gray-700" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }"> <div class="p-4 border-t border-gray-700" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }">
@ -196,38 +299,85 @@ SOFTWARE.
style="display: none;" style="display: none;"
@change="fileName = $event.target.files[0].name" @change="fileName = $event.target.files[0].name"
/> />
<form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt"> <form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt" class="max-w-3xl mx-auto">
<div class="relative w-full"> <div class="relative w-full bg-gray-800 rounded-xl shadow-md">
<textarea <textarea
id="input" id="input"
name="input" name="input"
x-model="inputValue" x-model="inputValue"
placeholder="Send a message..." 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" 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"
required required
@keydown.shift="shiftPressed = true" @keydown.shift="shiftPressed = true"
@keyup.shift="shiftPressed = false" @keyup.shift="shiftPressed = false"
@keydown.enter="if (!shiftPressed) { submitPrompt($event); }" @keydown.enter="if (!shiftPressed) { submitPrompt($event); }"
rows="3" rows="3"
style="box-shadow: 0 0 0 1px rgba(75, 85, 99, 0.4) inset;"
></textarea> ></textarea>
<span x-text="fileName" id="fileName" class="absolute right-16 top-3 text-gray-300 text-sm mr-2"></span> <span x-text="fileName" id="fileName" class="absolute right-16 top-4 text-gray-400 text-sm mr-2"></span>
<button <button
type="button" type="button"
onclick="document.getElementById('input_image').click()" 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" class="fa-solid fa-paperclip text-gray-400 absolute right-12 top-4 text-lg p-2 hover:text-blue-400 transition-colors duration-200"
title="Attach an image" title="Attach an image"
></button> ></button>
<button <button
type="submit" type="submit"
class="absolute right-2 top-3 text-lg p-2 text-gray-300 hover:text-gray-100" class="absolute right-3 top-4 text-lg p-2 text-gray-400 hover:text-blue-400 transition-colors duration-200"
title="Send message" title="Send message"
> >
<i class="fa-solid fa-circle-up"></i> <i class="fa-solid fa-paper-plane"></i>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- Modal moved outside of sidebar to appear in center of page -->
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Body -->
<div class="p-4 md:p-5 space-y-4">
<div class="flex justify-center items-center">
<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="{{ . }}" target="_blank">{{ . }}</a></li>
{{end}}
</ul>
</div>
<!-- Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
<!-- Alpine store initialization --> <!-- Alpine store initialization -->
<script> <script>
@ -235,6 +385,7 @@ SOFTWARE.
Alpine.store("chat", { Alpine.store("chat", {
history: [], history: [],
languages: [undefined], languages: [undefined],
systemPrompt: "",
clear() { clear() {
this.history.length = 0; this.history.length = 0;
}, },
@ -253,6 +404,7 @@ SOFTWARE.
}); });
this.history.push({ role, content, html: c, image }); this.history.push({ role, content, html: c, image });
} }
document.getElementById('messages').scrollIntoView(false);
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,
@ -277,6 +429,18 @@ SOFTWARE.
})); }));
}, },
}); });
window.copyToClipboard = (content) => {
const tempElement = document.createElement('div');
tempElement.innerHTML = content;
const text = tempElement.textContent || tempElement.innerText;
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard!');
}).catch(err => {
console.error('Failed to copy: ', err);
});
};
}); });
</script> </script>
</body> </body>

View File

@ -6,6 +6,7 @@
<div class="flex flex-col min-h-screen"> <div class="flex flex-col min-h-screen">
{{template "views/partials/navbar" .}} {{template "views/partials/navbar" .}}
{{ $numModelsPerPage := 21 }}
<div class="container mx-auto px-4 flex-grow"> <div class="container mx-auto px-4 flex-grow">
<div class="models mt-12"> <div class="models mt-12">
@ -20,37 +21,44 @@
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "tts"}' hx-vals='{"search": "tts"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >TTS</button> hx-indicator=".htmx-indicator" >TTS</button>
<button hx-post="browse/search/models" <button hx-post="browse/search/models"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "stablediffusion"}' hx-vals='{"search": "stablediffusion"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Image generation</button> hx-indicator=".htmx-indicator" >Image generation</button>
<button hx-post="browse/search/models" \ <button hx-post="browse/search/models" \
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "llm"}' hx-vals='{"search": "llm"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Text generation</button> hx-indicator=".htmx-indicator" >Text generation</button>
<button hx-post="browse/search/models" <button hx-post="browse/search/models"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "multimodal"}' hx-vals='{"search": "multimodal"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Multimodal</button> hx-indicator=".htmx-indicator" >Multimodal</button>
<button hx-post="browse/search/models" <button hx-post="browse/search/models"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "embedding"}' hx-vals='{"search": "embedding"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Embeddings</button> hx-indicator=".htmx-indicator" >Embeddings</button>
<button hx-post="browse/search/models" <button hx-post="browse/search/models"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "rerank"}' hx-vals='{"search": "rerank"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Rerankers</button> hx-indicator=".htmx-indicator" >Rerankers</button>
<button <button
hx-post="browse/search/models" hx-post="browse/search/models"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2" class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
hx-target="#search-results" hx-target="#search-results"
hx-vals='{"search": "whisper"}' hx-vals='{"search": "whisper"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >Audio transcription</button> hx-indicator=".htmx-indicator" >Audio transcription</button>
</div> </div>
@ -59,6 +67,7 @@
{{ range .AllTags }} {{ range .AllTags }}
<button hx-post="browse/search/models" class="text-blue-500" hx-target="#search-results" <button hx-post="browse/search/models" class="text-blue-500" hx-target="#search-results"
hx-vals='{"search": "{{.}}"}' hx-vals='{"search": "{{.}}"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator" >{{.}}</button> hx-indicator=".htmx-indicator" >{{.}}</button>
{{ end }} {{ end }}
</div> </div>
@ -72,16 +81,20 @@
hx-post="browse/search/models" hx-post="browse/search/models"
hx-trigger="input changed delay:500ms, search" hx-trigger="input changed delay:500ms, search"
hx-target="#search-results" hx-target="#search-results"
oninput="hidePagination()"
onchange="hidePagination()"
onsearch="hidePagination()"
hx-indicator=".htmx-indicator"> hx-indicator=".htmx-indicator">
<div id="search-results">{{.Models}}</div> <div id="search-results">{{.Models}}</div>
{{ if gt .AvailableModels $numModelsPerPage }}
<!-- Pagination --> <!-- Pagination -->
<div class="flex justify-center mt-5"> <div id="paginate" class="flex justify-center mt-5">
<div class="flex items <div class="flex items
-center"> -center">
<button onclick="window.location.href='browse?page={{.PrevPage}}'" class="bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-gray-200 px-3 py-1 rounded-l-md" {{if not .PrevPage}}disabled{{end}} <button onclick="window.location.href='browse?page={{.PrevPage}}'" class="bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-gray-200 px-3 py-1 rounded-l-md {{if not .PrevPage}}invisible{{end}}"
><i class="fas fa-arrow-left"></i></button> ><i class="fas fa-arrow-left"></i></button>
<button onclick="window.location.href='browse?page={{.NextPage}}'" class="bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-gray-200 px-3 py-1 rounded-r-md" {{if not .NextPage}}disabled{{end}} <button onclick="window.location.href='browse?page={{.NextPage}}'" class="bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-gray-200 px-3 py-1 rounded-r-md {{if not .NextPage}}invisible{{end}}"
><i class="fas fa-arrow-right"></i></button> ><i class="fas fa-arrow-right"></i></button>
<!-- <!--
TODO: do not refresh the page, but use htmx. TODO: do not refresh the page, but use htmx.
@ -105,9 +118,27 @@
--> -->
</div> </div>
</div> </div>
{{ end }}
</div>
</div> </div>
{{template "views/partials/footer" .}} {{template "views/partials/footer" .}}
</div> </div>
<script>
function hidePagination() {
const paginateDiv = document.getElementById('paginate');
if (paginateDiv) {
paginateDiv.style.display = 'none';
}
}
// Listen for the htmx:afterSwap event to handle cases when the search results are updated
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'search-results') {
hidePagination();
}
});
</script>
</body> </body>
</html> </html>