multi chat and history, oob swap

This commit is contained in:
Per Stark
2025-02-28 12:02:16 +01:00
parent efc2e481af
commit 8127a9adeb
12 changed files with 421 additions and 44 deletions
+131
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 { .tooltip {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -2544,12 +2601,21 @@
} }
} }
} }
.z-5 {
z-index: 5;
}
.z-10 {
z-index: 10;
}
.z-20 { .z-20 {
z-index: 20; z-index: 20;
} }
.z-50 { .z-50 {
z-index: 50; z-index: 50;
} }
.z-\[9999\] {
z-index: 9999;
}
.modal-box { .modal-box {
grid-column-start: 1; grid-column-start: 1;
grid-row-start: 1; grid-row-start: 1;
@@ -2639,6 +2705,12 @@
max-width: 96rem; max-width: 96rem;
} }
} }
.m-0 {
margin: calc(var(--spacing) * 0);
}
.m-9 {
margin: calc(var(--spacing) * 9);
}
.filter { .filter {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4072,6 +4144,9 @@
.h-32 { .h-32 {
height: calc(var(--spacing) * 32); height: calc(var(--spacing) * 32);
} }
.h-\[calc\(100vh-160px\)\] {
height: calc(100vh - 160px);
}
.min-h-\[100dvh\] { .min-h-\[100dvh\] {
min-height: 100dvh; min-height: 100dvh;
} }
@@ -4126,6 +4201,9 @@
.max-w-full { .max-w-full {
max-width: 100%; max-width: 100%;
} }
.max-w-xs {
max-width: var(--container-xs);
}
.min-w-72 { .min-w-72 {
min-width: calc(var(--spacing) * 72); min-width: calc(var(--spacing) * 72);
} }
@@ -4320,6 +4398,9 @@
.overflow-x-auto { .overflow-x-auto {
overflow-x: auto; overflow-x: auto;
} }
.overflow-y-auto {
overflow-y: auto;
}
.rounded-2xl { .rounded-2xl {
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
} }
@@ -4378,6 +4459,9 @@
.border-gray-200 { .border-gray-200 {
border-color: var(--color-gray-200); border-color: var(--color-gray-200);
} }
.border-neutral-700 {
border-color: var(--color-neutral-700);
}
.bg-accent { .bg-accent {
background-color: var(--color-accent); background-color: var(--color-accent);
} }
@@ -4393,6 +4477,9 @@
.bg-gray-200 { .bg-gray-200 {
background-color: var(--color-gray-200); background-color: var(--color-gray-200);
} }
.bg-neutral-800 {
background-color: var(--color-neutral-800);
}
.bg-secondary { .bg-secondary {
background-color: var(--color-secondary); background-color: var(--color-secondary);
} }
@@ -4429,12 +4516,18 @@
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
.p-5 { .p-5 {
padding: calc(var(--spacing) * 5); padding: calc(var(--spacing) * 5);
} }
.p-7 {
padding: calc(var(--spacing) * 7);
}
.menu-title { .menu-title {
padding-inline: calc(0.25rem * 3); padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2); padding-block: calc(0.25rem * 2);
@@ -4506,6 +4599,18 @@
.pb-10 { .pb-10 {
padding-bottom: calc(var(--spacing) * 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-center {
text-align: center; text-align: center;
} }
@@ -4646,6 +4751,9 @@
.text-transparent { .text-transparent {
color: transparent; color: transparent;
} }
.text-white {
color: var(--color-white);
}
.lowercase { .lowercase {
text-transform: lowercase; text-transform: lowercase;
} }
@@ -4672,6 +4780,9 @@
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
.opacity-0 {
opacity: 0%;
}
.opacity-50 { .opacity-50 {
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)); --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); 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 { .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)); --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); 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); --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\:text-blue-700 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -5128,6 +5250,15 @@
display: none; 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 { .prose-h1\:mb-2 {
& :is(:where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))) { & :is(:where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))) {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
+78 -9
View File
@@ -1,4 +1,5 @@
pub mod message_response_stream; pub mod message_response_stream;
pub mod references;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
@@ -16,7 +17,7 @@ use crate::{
page_data, page_data,
server::{routes::html::render_template, AppState}, server::{routes::html::render_template, AppState},
storage::{ storage::{
db::store_item, db::{get_item, store_item},
types::{ types::{
conversation::Conversation, conversation::Conversation,
message::{Message, MessageRole}, message::{Message, MessageRole},
@@ -46,7 +47,7 @@ where
page_data!(ChatData, "chat/base.html", { page_data!(ChatData, "chat/base.html", {
user: User, user: User,
history: Vec<Message>, history: Vec<Message>,
conversation: Conversation, conversation: Option<Conversation>,
conversation_archive: Vec<Conversation> conversation_archive: Vec<Conversation>
}); });
@@ -101,7 +102,7 @@ pub async fn show_initialized_chat(
history: messages, history: messages,
user, user,
conversation_archive, conversation_archive,
conversation: conversation.clone(), conversation: Some(conversation.clone()),
}, },
state.templates.clone(), state.templates.clone(),
)?; )?;
@@ -129,15 +130,13 @@ pub async fn show_chat_base(
.await .await
.map_err(|e| HtmlError::new(e, state.templates.clone()))?; .map_err(|e| HtmlError::new(e, state.templates.clone()))?;
let conversation = Conversation::new(user.id.clone(), "New Chat".to_string());
let output = render_template( let output = render_template(
ChatData::template_name(), ChatData::template_name(),
ChatData { ChatData {
history: vec![], history: vec![],
user, user,
conversation_archive, conversation_archive,
conversation, conversation: None,
}, },
state.templates.clone(), state.templates.clone(),
)?; )?;
@@ -179,7 +178,7 @@ pub async fn show_existing_chat(
ChatData { ChatData {
history: messages, history: messages,
user, user,
conversation: conversation.clone(), conversation: Some(conversation.clone()),
conversation_archive, conversation_archive,
}, },
state.templates.clone(), state.templates.clone(),
@@ -194,11 +193,28 @@ pub async fn new_user_message(
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>, auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Form(form): Form<NewMessageForm>, Form(form): Form<NewMessageForm>,
) -> Result<impl IntoResponse, HtmlError> { ) -> Result<impl IntoResponse, HtmlError> {
let _user = match auth.current_user { let user = match auth.current_user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/").into_response()), 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); let user_message = Message::new(conversation_id, MessageRole::User, form.content, None);
store_item(&state.surreal_db_client, user_message.clone()) store_item(&state.surreal_db_client, user_message.clone())
@@ -216,5 +232,58 @@ pub async fn new_user_message(
state.templates.clone(), 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)
} }
+62
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())
}
+6 -3
View File
@@ -17,8 +17,9 @@ use html::{
account::{delete_account, set_api_key, show_account_page, update_timezone}, account::{delete_account, set_api_key, show_account_page, update_timezone},
admin_panel::{show_admin_panel, toggle_registration_status}, admin_panel::{show_admin_panel, toggle_registration_status},
chat::{ chat::{
message_response_stream::get_response_stream, new_user_message, show_chat_base, message_response_stream::get_response_stream, new_chat_user_message, new_user_message,
show_existing_chat, show_initialized_chat, 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}, content::{patch_text_content, show_content_page, show_text_content_edit_form},
documentation::{ documentation::{
@@ -67,9 +68,11 @@ pub fn html_routes(app_state: &AppState) -> Router<AppState> {
.route("/gdpr/accept", post(accept_gdpr)) .route("/gdpr/accept", post(accept_gdpr))
.route("/gdpr/deny", post(deny_gdpr)) .route("/gdpr/deny", post(deny_gdpr))
.route("/search", get(search_result_handler)) .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/:id", get(show_existing_chat).post(new_user_message))
.route("/chat/response-stream", get(get_response_stream)) .route("/chat/response-stream", get(get_response_stream))
.route("/knowledge/:id", get(show_reference_tooltip))
.route("/signout", get(sign_out_user)) .route("/signout", get(sign_out_user))
.route("/signin", get(show_signin_form).post(authenticate_user)) .route("/signin", get(show_signin_form).post(authenticate_user))
.route( .route(
+17
View File
@@ -34,4 +34,21 @@ impl Message {
references, 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,
}
}
} }
+19 -17
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 %} {% for message in history %}
{% if message.role == "AI" %} {% if message.role == "AI" %}
<div class="chat chat-start"> <div class="chat chat-start">
<div class="chat-header">{{ message.role }}</div> <div>
<div class="chat-bubble"> <div class="chat-bubble">
{{ message.content }} {{ message.content }}
</div>
{% if message.references %}
{% include "chat/reference_list.html" %}
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="chat chat-end"> <div class="chat chat-end">
<div class="chat-header">{{ message.role }}</div>
<div class="chat-bubble"> <div class="chat-bubble">
{{ message.content }} {{ message.content }}
</div> </div>
@@ -19,21 +22,20 @@
</div> </div>
<script> <script>
// Scroll to latest message after HTMX swap
document.body.addEventListener('htmx:afterSwap', function (evt) { 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'); const chatContainer = document.getElementById('chat_container');
if (chatContainer) { if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight; chatContainer.scrollTop = chatContainer.scrollHeight;
} }
}); });
</script> </script>
<style>
#chat_container {
max-height: 70vh;
/* Adjust as needed */
overflow-y: auto;
/* Enable scrolling */
padding: 1rem;
}
</style>
@@ -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>
+3 -3
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"> <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="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2" <form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
id="chat-form"> 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" <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" 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> id="chat-input"></textarea>
+70 -10
View File
@@ -5,21 +5,19 @@
{% include "icons/chevron_icon.html" %} {% include "icons/chevron_icon.html" %}
</button> </button>
<div id="references-content-{{user_message_id}}" class="hidden max-w-full mt-1"> <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 %} {% for reference in references %}
<span class="badge badge-sm text-xs truncate max-w-[20ch] overflow-hidden text-left block tooltip" <div class="reference-badge-container" data-reference="{{reference}}" data-message-id="{{user_message_id}}"
hx-get="/knowledge/{{reference}}" hx-trigger="mouseenter once delay:500ms" data-index="{{loop.index}}">
hx-target="#tooltip-content-{{loop.index}}-{{user_message_id}}" hx-swap="innerHTML"> <span class="badge badge-xs badge-neutral truncate max-w-[20ch] overflow-hidden text-left block cursor-pointer">
{{reference}} {{reference}}
<div id="tooltip-content-{{loop.index}}-{{user_message_id}}" class="tooltip-content"> </span>
<!-- Loading indicator while content is fetched --> </div>
<div class="animate-pulse text-gray-400 text-xs">Loading...</div>
</div>
</span>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<script> <script>
document.getElementById('references-toggle-{{user_message_id}}').addEventListener('click', function () { document.getElementById('references-toggle-{{user_message_id}}').addEventListener('click', function () {
const content = document.getElementById('references-content-{{user_message_id}}'); const content = document.getElementById('references-content-{{user_message_id}}');
@@ -27,4 +25,66 @@
content.classList.toggle('hidden'); content.classList.toggle('hidden');
icon.classList.toggle('rotate-180'); 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> </script>
+3
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>
@@ -19,7 +19,8 @@
</div> </div>
</div>{% endif %} </div>{% endif %}
<div class="mt-4"> <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="user_query" value="{{ user_query }}">
<input type="hidden" name="llm_response" value="{{ answer_content }}"> <input type="hidden" name="llm_response" value="{{ answer_content }}">
<input type="hidden" name="references" value="{{ answer_references }}"> <input type="hidden" name="references" value="{{ answer_references }}">
+1 -1
View File
@@ -1,7 +1,7 @@
<nav class="navbar bg-base-200 !p-0"> <nav class="navbar bg-base-200 !p-0">
<div class="container flex mx-auto"> <div class="container flex mx-auto">
<div class="flex-1 flex items-center"> <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>
<div> <div>
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal px-1">