diff --git a/assets/style.css b/assets/style.css index 4f8f49d..c4fd15a 100644 --- a/assets/style.css +++ b/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); diff --git a/src/server/routes/html/chat/mod.rs b/src/server/routes/html/chat/mod.rs index 1e8207d..5b78c15 100644 --- a/src/server/routes/html/chat/mod.rs +++ b/src/server/routes/html/chat/mod.rs @@ -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, - conversation: Conversation, + conversation: Option, conversation_archive: Vec }); @@ -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, Surreal>, Form(form): Form, ) -> Result { - 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, + auth: AuthSession, Surreal>, + Form(form): Form, +) -> Result { + 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) } diff --git a/src/server/routes/html/chat/references.rs b/src/server/routes/html/chat/references.rs new file mode 100644 index 0000000..ee54d07 --- /dev/null +++ b/src/server/routes/html/chat/references.rs @@ -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, + auth: AuthSession, Surreal>, + Path(reference_id): Path, +) -> Result { + 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()) +} diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 5dd5bd4..8a3e8d6 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -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 { .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( diff --git a/src/storage/types/message.rs b/src/storage/types/message.rs index ca8afbf..546cfc9 100644 --- a/src/storage/types/message.rs +++ b/src/storage/types/message.rs @@ -34,4 +34,21 @@ impl Message { references, } } + pub fn new_ai_message( + conversation_id: String, + id: String, + content: String, + references: Option>, + ) -> Self { + let now = Utc::now(); + Self { + id, + created_at: now, + updated_at: now, + role: MessageRole::AI, + content, + references, + conversation_id, + } + } } diff --git a/templates/chat/history.html b/templates/chat/history.html index f647292..0c367e4 100644 --- a/templates/chat/history.html +++ b/templates/chat/history.html @@ -1,15 +1,18 @@ -
+
{% for message in history %} {% if message.role == "AI" %}
-
{{ message.role }}
-
- {{ message.content }} +
+
+ {{ message.content }} +
+ {% if message.references %} + {% include "chat/reference_list.html" %} + {% endif %}
{% else %}
-
{{ message.role }}
{{ message.content }}
@@ -19,21 +22,20 @@
- - \ No newline at end of file + \ No newline at end of file diff --git a/templates/chat/new_chat_first_response.html b/templates/chat/new_chat_first_response.html new file mode 100644 index 0000000..03777ae --- /dev/null +++ b/templates/chat/new_chat_first_response.html @@ -0,0 +1,29 @@ +{% include "chat/streaming_response.html" %} + + +
+ + + +
+ \ No newline at end of file diff --git a/templates/chat/new_message_form.html b/templates/chat/new_message_form.html index b8ed3c6..226d117 100644 --- a/templates/chat/new_message_form.html +++ b/templates/chat/new_message_form.html @@ -1,6 +1,6 @@ -
-
+
+ diff --git a/templates/chat/reference_list.html b/templates/chat/reference_list.html index ca8263b..a6a99c1 100644 --- a/templates/chat/reference_list.html +++ b/templates/chat/reference_list.html @@ -5,21 +5,19 @@ {% include "icons/chevron_icon.html" %} + \ No newline at end of file diff --git a/templates/chat/reference_tooltip.html b/templates/chat/reference_tooltip.html new file mode 100644 index 0000000..3b8cd97 --- /dev/null +++ b/templates/chat/reference_tooltip.html @@ -0,0 +1,3 @@ +
{{entity.name}}
+
{{entity.description}}
+
{{entity.updated_at|datetimeformat(format="short", tz=user.timezone)}}
\ No newline at end of file diff --git a/templates/index/signed_in/search_response.html b/templates/index/signed_in/search_response.html index e009ee3..111c840 100644 --- a/templates/index/signed_in/search_response.html +++ b/templates/index/signed_in/search_response.html @@ -19,7 +19,8 @@
{% endif %}
- + diff --git a/templates/navigation_bar.html b/templates/navigation_bar.html index 2a7620e..82d10f2 100644 --- a/templates/navigation_bar.html +++ b/templates/navigation_bar.html @@ -1,7 +1,7 @@