multi chat and history, oob swap

This commit is contained in:
Per Stark
2025-02-28 12:02:16 +01:00
parent 21f0ebef33
commit 65c5900189
12 changed files with 421 additions and 44 deletions

View File

@@ -828,6 +828,63 @@
}
}
}
.\!tooltip {
position: relative !important;
display: inline-block !important;
--tt-bg: var(--color-neutral) !important;
--tt-off: calc(100% + 0.5rem) !important;
--tt-tail: calc(100% + 1px + 0.25rem) !important;
> :where(.tooltip-content), &[data-tip]:before {
position: absolute !important;
max-width: 20rem !important;
border-radius: var(--radius-field) !important;
padding-inline: calc(0.25rem * 2) !important;
padding-block: calc(0.25rem * 1) !important;
text-align: center !important;
white-space: normal !important;
color: var(--color-neutral-content) !important;
opacity: 0% !important;
font-size: 0.875rem !important;
line-height: 1.25em !important;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms !important;
background-color: var(--tt-bg) !important;
width: max-content !important;
pointer-events: none !important;
z-index: 1 !important;
--tw-content: attr(data-tip) !important;
content: var(--tw-content) !important;
}
&:after {
position: absolute !important;
position: absolute !important;
opacity: 0% !important;
background-color: var(--tt-bg) !important;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms !important;
content: "" !important;
pointer-events: none !important;
width: 0.625rem !important;
height: 0.25rem !important;
display: block !important;
mask-repeat: no-repeat !important;
mask-position: -1px 0 !important;
mask-image: url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A") !important;
}
&.tooltip-open, &[data-tip]:hover, &:hover, &:has(:focus-visible) {
> .tooltip-content, &[data-tip]:before, &:after {
opacity: 100% !important;
--tt-pos: 0rem !important;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0ms !important;
}
}
> .tooltip-content, &[data-tip]:before {
transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem)) !important;
inset: auto auto var(--tt-off) 50% !important;
}
&:after {
transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem)) !important;
inset: auto auto var(--tt-tail) 50% !important;
}
}
.tooltip {
position: relative;
display: inline-block;
@@ -2544,12 +2601,21 @@
}
}
}
.z-5 {
z-index: 5;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-50 {
z-index: 50;
}
.z-\[9999\] {
z-index: 9999;
}
.modal-box {
grid-column-start: 1;
grid-row-start: 1;
@@ -2639,6 +2705,12 @@
max-width: 96rem;
}
}
.m-0 {
margin: calc(var(--spacing) * 0);
}
.m-9 {
margin: calc(var(--spacing) * 9);
}
.filter {
display: flex;
flex-wrap: wrap;
@@ -4072,6 +4144,9 @@
.h-32 {
height: calc(var(--spacing) * 32);
}
.h-\[calc\(100vh-160px\)\] {
height: calc(100vh - 160px);
}
.min-h-\[100dvh\] {
min-height: 100dvh;
}
@@ -4126,6 +4201,9 @@
.max-w-full {
max-width: 100%;
}
.max-w-xs {
max-width: var(--container-xs);
}
.min-w-72 {
min-width: calc(var(--spacing) * 72);
}
@@ -4320,6 +4398,9 @@
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
.rounded-2xl {
border-radius: var(--radius-2xl);
}
@@ -4378,6 +4459,9 @@
.border-gray-200 {
border-color: var(--color-gray-200);
}
.border-neutral-700 {
border-color: var(--color-neutral-700);
}
.bg-accent {
background-color: var(--color-accent);
}
@@ -4393,6 +4477,9 @@
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-neutral-800 {
background-color: var(--color-neutral-800);
}
.bg-secondary {
background-color: var(--color-secondary);
}
@@ -4429,12 +4516,18 @@
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-5 {
padding: calc(var(--spacing) * 5);
}
.p-7 {
padding: calc(var(--spacing) * 7);
}
.menu-title {
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2);
@@ -4506,6 +4599,18 @@
.pb-10 {
padding-bottom: calc(var(--spacing) * 10);
}
.pb-32 {
padding-bottom: calc(var(--spacing) * 32);
}
.pl-2 {
padding-left: calc(var(--spacing) * 2);
}
.pl-3 {
padding-left: calc(var(--spacing) * 3);
}
.pl-4 {
padding-left: calc(var(--spacing) * 4);
}
.text-center {
text-align: center;
}
@@ -4646,6 +4751,9 @@
.text-transparent {
color: transparent;
}
.text-white {
color: var(--color-white);
}
.lowercase {
text-transform: lowercase;
}
@@ -4672,6 +4780,9 @@
.underline {
text-decoration-line: underline;
}
.opacity-0 {
opacity: 0%;
}
.opacity-50 {
opacity: 50%;
}
@@ -4693,6 +4804,10 @@
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -4790,6 +4905,13 @@
--tw-prose-td-borders: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
.hover\:bg-gray-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-200);
}
}
}
.hover\:text-blue-700 {
&:hover {
@media (hover: hover) {
@@ -5128,6 +5250,15 @@
display: none;
}
}
.dark\:hover\:bg-gray-700 {
@media (prefers-color-scheme: dark) {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-700);
}
}
}
}
.prose-h1\:mb-2 {
& :is(:where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))) {
margin-bottom: calc(var(--spacing) * 2);

View File

@@ -1,4 +1,5 @@
pub mod message_response_stream;
pub mod references;
use axum::{
extract::{Path, State},
@@ -16,7 +17,7 @@ use crate::{
page_data,
server::{routes::html::render_template, AppState},
storage::{
db::store_item,
db::{get_item, store_item},
types::{
conversation::Conversation,
message::{Message, MessageRole},
@@ -46,7 +47,7 @@ where
page_data!(ChatData, "chat/base.html", {
user: User,
history: Vec<Message>,
conversation: Conversation,
conversation: Option<Conversation>,
conversation_archive: Vec<Conversation>
});
@@ -101,7 +102,7 @@ pub async fn show_initialized_chat(
history: messages,
user,
conversation_archive,
conversation: conversation.clone(),
conversation: Some(conversation.clone()),
},
state.templates.clone(),
)?;
@@ -129,15 +130,13 @@ pub async fn show_chat_base(
.await
.map_err(|e| HtmlError::new(e, state.templates.clone()))?;
let conversation = Conversation::new(user.id.clone(), "New Chat".to_string());
let output = render_template(
ChatData::template_name(),
ChatData {
history: vec![],
user,
conversation_archive,
conversation,
conversation: None,
},
state.templates.clone(),
)?;
@@ -179,7 +178,7 @@ pub async fn show_existing_chat(
ChatData {
history: messages,
user,
conversation: conversation.clone(),
conversation: Some(conversation.clone()),
conversation_archive,
},
state.templates.clone(),
@@ -194,11 +193,28 @@ pub async fn new_user_message(
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Form(form): Form<NewMessageForm>,
) -> Result<impl IntoResponse, HtmlError> {
let _user = match auth.current_user {
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let conversation: Conversation = get_item(&state.surreal_db_client, &conversation_id)
.await
.map_err(|e| HtmlError::new(AppError::from(e), state.templates.clone()))?
.ok_or_else(|| {
HtmlError::new(
AppError::NotFound("Conversation was not found".to_string()),
state.templates.clone(),
)
})?;
if conversation.user_id != user.id {
return Err(HtmlError::new(
AppError::Auth("The user does not have permission for this conversation".to_string()),
state.templates.clone(),
));
};
let user_message = Message::new(conversation_id, MessageRole::User, form.content, None);
store_item(&state.surreal_db_client, user_message.clone())
@@ -216,5 +232,58 @@ pub async fn new_user_message(
state.templates.clone(),
)?;
Ok(output.into_response())
let mut response = output.into_response();
response.headers_mut().insert(
"HX-Push",
HeaderValue::from_str(&format!("/chat/{}", conversation.id)).unwrap(),
);
Ok(response)
}
pub async fn new_chat_user_message(
State(state): State<AppState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Form(form): Form<NewMessageForm>,
) -> Result<impl IntoResponse, HtmlError> {
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let conversation = Conversation::new(user.id, "New chat".to_string());
let user_message = Message::new(
conversation.id.clone(),
MessageRole::User,
form.content,
None,
);
store_item(&state.surreal_db_client, conversation.clone())
.await
.map_err(|e| HtmlError::new(AppError::from(e), state.templates.clone()))?;
store_item(&state.surreal_db_client, user_message.clone())
.await
.map_err(|e| HtmlError::new(AppError::from(e), state.templates.clone()))?;
#[derive(Serialize)]
struct SSEResponseInitData {
user_message: Message,
conversation: Conversation,
}
let output = render_template(
"chat/new_chat_first_response.html",
SSEResponseInitData {
user_message,
conversation: conversation.clone(),
},
state.templates.clone(),
)?;
let mut response = output.into_response();
response.headers_mut().insert(
"HX-Push",
HeaderValue::from_str(&format!("/chat/{}", conversation.id)).unwrap(),
);
Ok(response)
}

View File

@@ -0,0 +1,62 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Redirect},
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use serde::Serialize;
use surrealdb::{engine::any::Any, Surreal};
use tracing::info;
use crate::{
error::{AppError, HtmlError},
server::{routes::html::render_template, AppState},
storage::{
db::get_item,
types::{knowledge_entity::KnowledgeEntity, user::User},
},
};
pub async fn show_reference_tooltip(
State(state): State<AppState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Path(reference_id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
info!("Showing reference");
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let entity: KnowledgeEntity = get_item(&state.surreal_db_client, &reference_id)
.await
.map_err(|e| HtmlError::new(AppError::from(e), state.templates.clone()))?
.ok_or_else(|| {
HtmlError::new(
AppError::NotFound("Item was not found".to_string()),
state.templates.clone(),
)
})?;
if entity.user_id != user.id {
return Err(HtmlError::new(
AppError::Auth("You dont have access to this entity".to_string()),
state.templates.clone(),
));
}
#[derive(Serialize)]
struct ReferenceTooltipData {
entity: KnowledgeEntity,
user: User,
}
let output = render_template(
"chat/reference_tooltip.html",
ReferenceTooltipData { entity, user },
state.templates.clone(),
)?;
Ok(output.into_response())
}

View File

@@ -17,8 +17,9 @@ use html::{
account::{delete_account, set_api_key, show_account_page, update_timezone},
admin_panel::{show_admin_panel, toggle_registration_status},
chat::{
message_response_stream::get_response_stream, new_user_message, show_chat_base,
show_existing_chat, show_initialized_chat,
message_response_stream::get_response_stream, new_chat_user_message, new_user_message,
references::show_reference_tooltip, show_chat_base, show_existing_chat,
show_initialized_chat,
},
content::{patch_text_content, show_content_page, show_text_content_edit_form},
documentation::{
@@ -67,9 +68,11 @@ pub fn html_routes(app_state: &AppState) -> Router<AppState> {
.route("/gdpr/accept", post(accept_gdpr))
.route("/gdpr/deny", post(deny_gdpr))
.route("/search", get(search_result_handler))
.route("/chat", get(show_chat_base).post(show_initialized_chat))
.route("/chat", get(show_chat_base).post(new_chat_user_message))
.route("/initialized-chat", post(show_initialized_chat))
.route("/chat/:id", get(show_existing_chat).post(new_user_message))
.route("/chat/response-stream", get(get_response_stream))
.route("/knowledge/:id", get(show_reference_tooltip))
.route("/signout", get(sign_out_user))
.route("/signin", get(show_signin_form).post(authenticate_user))
.route(

View File

@@ -34,4 +34,21 @@ impl Message {
references,
}
}
pub fn new_ai_message(
conversation_id: String,
id: String,
content: String,
references: Option<Vec<String>>,
) -> Self {
let now = Utc::now();
Self {
id,
created_at: now,
updated_at: now,
role: MessageRole::AI,
content,
references,
conversation_id,
}
}
}

View File

@@ -1,15 +1,18 @@
<div id="chat_container">
<div id="chat_container" class="pl-3 overflow-y-auto h-[calc(100vh-160px)] pb-32">
{% for message in history %}
{% if message.role == "AI" %}
<div class="chat chat-start">
<div class="chat-header">{{ message.role }}</div>
<div class="chat-bubble">
{{ message.content }}
<div>
<div class="chat-bubble">
{{ message.content }}
</div>
{% if message.references %}
{% include "chat/reference_list.html" %}
{% endif %}
</div>
</div>
{% else %}
<div class="chat chat-end">
<div class="chat-header">{{ message.role }}</div>
<div class="chat-bubble">
{{ message.content }}
</div>
@@ -19,21 +22,20 @@
</div>
<script>
// Scroll to latest message after HTMX swap
document.body.addEventListener('htmx:afterSwap', function (evt) {
const chatContainer = document.getElementById('chat_container');
if (chatContainer) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 0);
}
});
// Also scroll when page loads
window.addEventListener('load', function () {
const chatContainer = document.getElementById('chat_container');
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
</script>
<style>
#chat_container {
max-height: 70vh;
/* Adjust as needed */
overflow-y: auto;
/* Enable scrolling */
padding: 1rem;
}
</style>
</script>

View File

@@ -0,0 +1,29 @@
{% include "chat/streaming_response.html" %}
<!-- OOB swap targeting the form element directly -->
<form id="chat-form" hx-post="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend"
class="relative flex gap-2" hx-swap-oob="true">
<textarea autofocus required name="content" placeholder="Type your message..." rows="2"
class="textarea textarea-ghost rounded-2xl rounded-b-none h-24 sm:rounded-b-2xl pr-8 bg-base-200 flex-grow resize-none"
id="chat-input"></textarea>
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1">
{% include "icons/send_icon.html" %}
</button>
<label for="my-drawer-2" class="absolute cursor-pointer top-9 right-0.5 p-2 drawer-button xl:hidden z-20 ">
{% include "icons/hamburger_icon.html" %}
</label>
</form>
<script>
document.getElementById('chat-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
htmx.trigger('#chat-form', 'submit');
}
});
// Clear textarea after successful submission
document.getElementById('chat-form').addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) { // Check if the request was successful
document.getElementById('chat-input').value = ''; // Clear the textarea
}
});
</script>

View File

@@ -1,6 +1,6 @@
<div class="fixed w-full mx-auto max-w-3xl p-4 pb-0 sm:pb-4 left-0 right-0 bottom-0">
<form hx-post="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2"
id="chat-form">
<div class="fixed w-full mx-auto max-w-3xl p-4 pb-0 sm:pb-4 left-0 right-0 bottom-0 bg-base-100 z-10">
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2" id="chat-form">
<textarea autofocus required name="content" placeholder="Type your message..." rows="2"
class="textarea textarea-ghost rounded-2xl rounded-b-none h-24 sm:rounded-b-2xl pr-8 bg-base-200 flex-grow resize-none"
id="chat-input"></textarea>

View File

@@ -5,21 +5,19 @@
{% include "icons/chevron_icon.html" %}
</button>
<div id="references-content-{{user_message_id}}" class="hidden max-w-full mt-1">
<div class="flex flex-wrap">
<div class="flex flex-wrap gap-1">
{% for reference in references %}
<span class="badge badge-sm text-xs truncate max-w-[20ch] overflow-hidden text-left block tooltip"
hx-get="/knowledge/{{reference}}" hx-trigger="mouseenter once delay:500ms"
hx-target="#tooltip-content-{{loop.index}}-{{user_message_id}}" hx-swap="innerHTML">
{{reference}}
<div id="tooltip-content-{{loop.index}}-{{user_message_id}}" class="tooltip-content">
<!-- Loading indicator while content is fetched -->
<div class="animate-pulse text-gray-400 text-xs">Loading...</div>
</div>
</span>
<div class="reference-badge-container" data-reference="{{reference}}" data-message-id="{{user_message_id}}"
data-index="{{loop.index}}">
<span class="badge badge-xs badge-neutral truncate max-w-[20ch] overflow-hidden text-left block cursor-pointer">
{{reference}}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
<script>
document.getElementById('references-toggle-{{user_message_id}}').addEventListener('click', function () {
const content = document.getElementById('references-content-{{user_message_id}}');
@@ -27,4 +25,66 @@
content.classList.toggle('hidden');
icon.classList.toggle('rotate-180');
});
// Initialize portal tooltips
document.querySelectorAll('.reference-badge-container').forEach(container => {
const reference = container.dataset.reference;
const messageId = container.dataset.messageId;
const index = container.dataset.index;
let tooltipId = `tooltip-${messageId}-${index}`;
let tooltipContent = null;
let tooltipTimeout;
// Create tooltip element (initially hidden)
function createTooltip() {
const tooltip = document.createElement('div');
tooltip.id = tooltipId;
tooltip.className = 'fixed z-[9999] bg-neutral-800 text-white p-3 rounded-md shadow-lg text-sm w-72 max-w-xs border border-neutral-700 hidden';
tooltip.innerHTML = '<div class="animate-pulse">Loading...</div>';
document.body.appendChild(tooltip);
return tooltip;
}
container.addEventListener('mouseenter', function () {
// Clear any existing timeout
if (tooltipTimeout) clearTimeout(tooltipTimeout);
// Get or create tooltip
let tooltip = document.getElementById(tooltipId);
if (!tooltip) tooltip = createTooltip();
// Position tooltip
const rect = container.getBoundingClientRect();
tooltip.style.top = `${rect.bottom + window.scrollY + 5}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
// Adjust position if it would overflow viewport
const tooltipRect = tooltip.getBoundingClientRect();
if (rect.left + tooltipRect.width > window.innerWidth - 20) {
tooltip.style.left = `${window.innerWidth - tooltipRect.width - 20 + window.scrollX}px`;
}
// Show tooltip
tooltip.classList.remove('hidden');
// Load content if needed
if (!tooltipContent) {
fetch(`/knowledge/${encodeURIComponent(reference)}`)
.then(response => response.text())
.then(html => {
tooltipContent = html;
if (document.getElementById(tooltipId)) {
document.getElementById(tooltipId).innerHTML = html;
}
});
}
});
container.addEventListener('mouseleave', function () {
tooltipTimeout = setTimeout(() => {
const tooltip = document.getElementById(tooltipId);
if (tooltip) tooltip.classList.add('hidden');
}, 200);
});
});
</script>

View File

@@ -0,0 +1,3 @@
<div>{{entity.name}}</div>
<div>{{entity.description}}</div>
<div>{{entity.updated_at|datetimeformat(format="short", tz=user.timezone)}} </div>

View File

@@ -19,7 +19,8 @@
</div>
</div>{% endif %}
<div class="mt-4">
<form hx-post="/chat" hx-target="body" hx-swap="outerHTML" method="POST" class="flex items-center space-x-4">
<form hx-post="/initialized-chat" hx-target="body" hx-swap="outerHTML" method="POST"
class="flex items-center space-x-4">
<input type="hidden" name="user_query" value="{{ user_query }}">
<input type="hidden" name="llm_response" value="{{ answer_content }}">
<input type="hidden" name="references" value="{{ answer_references }}">

View File

@@ -1,7 +1,7 @@
<nav class="navbar bg-base-200 !p-0">
<div class="container flex mx-auto">
<div class="flex-1 flex items-center">
<a class="text-2xl text-primary font-bold" href="/" hx-boost="true">Minne</a>
<a class="text-2xl p-2 text-primary font-bold" href="/" hx-boost="true">Minne</a>
</div>
<div>
<ul class="menu menu-horizontal px-1">