mirror of
https://github.com/perstarkse/minne.git
synced 2026-03-21 17:09:51 +01:00
multi chat and history, oob swap
This commit is contained in:
131
assets/style.css
131
assets/style.css
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
62
src/server/routes/html/chat/references.rs
Normal file
62
src/server/routes/html/chat/references.rs
Normal 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())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
29
templates/chat/new_chat_first_response.html
Normal file
29
templates/chat/new_chat_first_response.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
3
templates/chat/reference_tooltip.html
Normal file
3
templates/chat/reference_tooltip.html
Normal 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>{% 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 }}">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user