diff --git a/Cargo.lock b/Cargo.lock index 8e42aa1..c2146ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6041,6 +6041,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-openai", + "async-stream", "axum", "axum-htmx", "axum_session", diff --git a/Cargo.toml b/Cargo.toml index b55c00e..acd27f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0.94" async-openai = "0.24.1" +async-stream = "0.3.6" axum = { version = "0.7.5", features = ["multipart", "macros"] } axum-htmx = "0.6.0" axum_session = "0.14.4" diff --git a/assets/style.css b/assets/style.css index 754c8c3..4f8f49d 100644 --- a/assets/style.css +++ b/assets/style.css @@ -2057,6 +2057,21 @@ } } } + .indicator { + position: relative; + display: inline-flex; + width: max-content; + :where(.indicator-item) { + z-index: 1; + position: absolute; + white-space: nowrap; + top: var(--inidicator-t, 0); + bottom: var(--inidicator-b, auto); + left: var(--inidicator-s, auto); + right: var(--inidicator-e, 0); + translate: var(--inidicator-x, 50%) var(--indicator-y, -50%); + } + } .collapse-title { grid-column-start: 1; grid-row-start: 1; @@ -2158,21 +2173,9 @@ .top-1 { top: calc(var(--spacing) * 1); } - .top-2 { - top: calc(var(--spacing) * 2); - } - .top-5 { - top: calc(var(--spacing) * 5); - } - .top-8 { - top: calc(var(--spacing) * 8); - } .top-9 { top: calc(var(--spacing) * 9); } - .top-10 { - top: calc(var(--spacing) * 10); - } .right-0 { right: calc(var(--spacing) * 0); } @@ -2541,33 +2544,12 @@ } } } - .z-0 { - z-index: 0; - } - .z-1 { - z-index: 1; - } - .z-2 { - z-index: 2; - } - .z-3 { - z-index: 3; - } - .z-5 { - z-index: 5; - } .z-20 { z-index: 20; } .z-50 { z-index: 50; } - .z-\[\12 ަ��\$���w�����T�\:��G��\1e \] { - z-index: ަ��$���w�����T�:��G��; - } - .z-\[\12 ަ��\$���ӕ\/\12 h�Jc�\1a \\���\=�i\] { - z-index: ަ��$���ӕ/h�Jc�\���=�i; - } .modal-box { grid-column-start: 1; grid-row-start: 1; @@ -2639,9 +2621,6 @@ .list-col-wrap { grid-row-start: 2; } - .float-left { - float: left; - } .container { width: 100%; @media (width >= 40rem) { @@ -2660,12 +2639,6 @@ max-width: 96rem; } } - .m-2 { - margin: calc(var(--spacing) * 2); - } - .m-9 { - margin: calc(var(--spacing) * 9); - } .filter { display: flex; flex-wrap: wrap; @@ -2783,6 +2756,12 @@ } } } + .my-0 { + margin-block: calc(var(--spacing) * 0); + } + .my-2 { + margin-block: calc(var(--spacing) * 2); + } .my-4 { margin-block: calc(var(--spacing) * 4); } @@ -3679,6 +3658,9 @@ font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } .mt-2 { margin-top: calc(var(--spacing) * 2); } @@ -3719,6 +3701,9 @@ .mb-10 { margin-bottom: calc(var(--spacing) * 10); } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } .alert { display: grid; width: 100%; @@ -3814,20 +3799,6 @@ background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), transparent ); box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), transparent); } - .status\! { - display: inline-block !important; - aspect-ratio: 1 / 1 !important; - width: calc(0.25rem * 2) !important; - height: calc(0.25rem * 2) !important; - border-radius: var(--radius-selector) !important; - background-color: color-mix(in oklab, var(--color-base-content) 20%, transparent) !important; - background-position: center !important; - background-repeat: no-repeat !important; - vertical-align: middle !important; - color: color-mix(in oklab, var(--color-black) 30%, transparent) !important; - background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), transparent ) !important; - box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), transparent) !important; - } .kbd { display: inline-flex; align-items: center; @@ -4089,27 +4060,18 @@ width: calc(var(--spacing) * 10); height: calc(var(--spacing) * 10); } - .h-4 { - height: calc(var(--spacing) * 4); + .h-3 { + height: calc(var(--spacing) * 3); } .h-5 { height: calc(var(--spacing) * 5); } - .h-6 { - height: calc(var(--spacing) * 6); - } - .h-9 { - height: calc(var(--spacing) * 9); - } .h-24 { height: calc(var(--spacing) * 24); } .h-32 { height: calc(var(--spacing) * 32); } - .h-36 { - height: calc(var(--spacing) * 36); - } .min-h-\[100dvh\] { min-height: 100dvh; } @@ -4122,11 +4084,8 @@ .loading-sm { width: calc(var(--size-selector, 0.25rem) * 5); } - .w-1 { - width: calc(var(--spacing) * 1); - } - .w-2 { - width: calc(var(--spacing) * 2); + .w-3 { + width: calc(var(--spacing) * 3); } .w-5 { width: calc(var(--spacing) * 5); @@ -4137,9 +4096,6 @@ .w-72 { width: calc(var(--spacing) * 72); } - .w-\[���\}�C�9JO\1 �j\] { - width: ���}�C�9JO�j; - } .w-full { width: 100%; } @@ -4152,6 +4108,18 @@ .max-w-3xl { max-width: var(--container-3xl); } + .max-w-\[10ch\] { + max-width: 10ch; + } + .max-w-\[20ch\] { + max-width: 20ch; + } + .max-w-\[50px\] { + max-width: 50px; + } + .max-w-\[90\%\] { + max-width: 90%; + } .max-w-\[160px\] { max-width: 160px; } @@ -4188,6 +4156,9 @@ --tw-scale-z: 75%; scale: var(--tw-scale-x) var(--tw-scale-y); } + .rotate-180 { + rotate: 180deg; + } .swap-rotate { .swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on { rotate: 45deg; @@ -4225,6 +4196,9 @@ background-repeat: no-repeat; background-position-x: -50%; } + .animate-pulse { + animation: var(--animate-pulse); + } .link { cursor: pointer; text-decoration-line: underline; @@ -4278,6 +4252,9 @@ .justify-start { justify-content: flex-start; } + .gap-1 { + gap: calc(var(--spacing) * 1); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -4337,6 +4314,9 @@ .overflow-clip { overflow: clip; } + .overflow-hidden { + overflow: hidden; + } .overflow-x-auto { overflow-x: auto; } @@ -4355,9 +4335,8 @@ .rounded-lg { border-radius: var(--radius-lg); } - .rounded-t-2xl { - border-top-left-radius: var(--radius-2xl); - border-top-right-radius: var(--radius-2xl); + .rounded-md { + border-radius: var(--radius-md); } .rounded-t-none { border-top-left-radius: 0; @@ -4371,10 +4350,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .border-2 { - border-style: var(--tw-border-style); - border-width: 2px; - } .textarea-ghost { background-color: transparent; box-shadow: none; @@ -4391,14 +4366,17 @@ color: var(--color-success-content); --alert-color: var(--color-success); } + .border-base-300 { + border-color: var(--color-base-300); + } .border-base-content { border-color: var(--color-base-content); } .border-base-content\/5 { border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent); } - .border-primary { - border-color: var(--color-primary); + .border-gray-200 { + border-color: var(--color-gray-200); } .bg-accent { background-color: var(--color-accent); @@ -4409,6 +4387,12 @@ .bg-base-200 { background-color: var(--color-base-200); } + .bg-gray-50 { + background-color: var(--color-gray-50); + } + .bg-gray-200 { + background-color: var(--color-gray-200); + } .bg-secondary { background-color: var(--color-secondary); } @@ -4419,12 +4403,6 @@ --tw-gradient-position: to right in oklab,; background-image: linear-gradient(var(--tw-gradient-stops)); } - .\!bg-none { - background-image: none !important; - } - .bg-none { - background-image: none; - } .from-primary { --tw-gradient-from: var(--color-primary); --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position,) var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); @@ -4451,18 +4429,12 @@ .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-9 { - padding: calc(var(--spacing) * 9); - } .menu-title { padding-inline: calc(0.25rem * 3); padding-block: calc(0.25rem * 2); @@ -4470,6 +4442,11 @@ font-size: 0.875rem; font-weight: 600; } + .badge-sm { + --size: calc(var(--size-selector, 0.25rem) * 5); + font-size: 0.75rem; + padding-inline: calc(0.25rem * 2.5 - var(--border)); + } .badge-xs { --size: calc(var(--size-selector, 0.25rem) * 4); font-size: 0.625rem; @@ -4493,14 +4470,23 @@ .px-1 { padding-inline: calc(var(--spacing) * 1); } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } .px-4 { padding-inline: calc(var(--spacing) * 4); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } .py-4 { padding-block: calc(var(--spacing) * 4); } - .pt-5 { - padding-top: calc(var(--spacing) * 5); + .pt-1 { + padding-top: calc(var(--spacing) * 1); } .pt-10 { padding-top: calc(var(--spacing) * 10); @@ -4517,18 +4503,15 @@ .pb-0 { padding-bottom: calc(var(--spacing) * 0); } - .pb-4 { - padding-bottom: calc(var(--spacing) * 4); - } - .pb-8 { - padding-bottom: calc(var(--spacing) * 8); - } .pb-10 { padding-bottom: calc(var(--spacing) * 10); } .text-center { text-align: center; } + .text-left { + text-align: left; + } .font-satoshi { font-family: Satoshi, sans-serif; } @@ -4586,6 +4569,10 @@ --tw-font-weight: var(--font-weight-light); font-weight: var(--font-weight-light); } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); @@ -4603,6 +4590,10 @@ .whitespace-pre-wrap { white-space: pre-wrap; } + .badge-neutral { + --badge-color: var(--color-neutral); + color: var(--color-neutral-content); + } .badge-primary { --badge-color: var(--color-primary); color: var(--color-primary-content); @@ -4625,12 +4616,21 @@ .text-base-content\/60 { color: color-mix(in oklab, var(--color-base-content) 60%, transparent); } + .text-blue-500 { + color: var(--color-blue-500); + } .text-error { color: var(--color-error); } + .text-gray-400 { + color: var(--color-gray-400); + } .text-gray-500 { color: var(--color-gray-500); } + .text-gray-700 { + color: var(--color-gray-700); + } .text-primary { color: var(--color-primary); } @@ -4672,6 +4672,9 @@ .underline { text-decoration-line: underline; } + .opacity-50 { + opacity: 50%; + } .opacity-60 { opacity: 60%; } @@ -4722,6 +4725,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); @@ -4734,9 +4742,6 @@ --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } - .\[a-zA-Z\:\\-\] { - a-zA-Z: \-; - } .btn-accent { --btn-color: var(--color-accent); --btn-fg: var(--color-accent-content); @@ -4785,6 +4790,13 @@ --tw-prose-td-borders: color-mix(in oklab, var(--color-base-content) 20%, transparent); } } + .hover\:text-blue-700 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-700); + } + } + } .hover\:text-gray-700 { &:hover { @media (hover: hover) { @@ -4792,21 +4804,24 @@ } } } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } .sm\:mt-4 { @media (width >= 40rem) { margin-top: calc(var(--spacing) * 4); } } - .sm\:mb-2 { - @media (width >= 40rem) { - margin-bottom: calc(var(--spacing) * 2); - } - } - .sm\:mb-5 { - @media (width >= 40rem) { - margin-bottom: calc(var(--spacing) * 5); - } - } .sm\:max-w-md { @media (width >= 40rem) { max-width: var(--container-md); @@ -4828,12 +4843,6 @@ border-bottom-left-radius: var(--radius-2xl); } } - .sm\:rounded-b-none { - @media (width >= 40rem) { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } - } .sm\:px-0 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 0); diff --git a/src/server/routes/html/chat/message_response_stream.rs b/src/server/routes/html/chat/message_response_stream.rs index 659cf94..1837bed 100644 --- a/src/server/routes/html/chat/message_response_stream.rs +++ b/src/server/routes/html/chat/message_response_stream.rs @@ -1,15 +1,24 @@ -use std::{pin::Pin, time::Duration}; +use std::{pin::Pin, sync::Arc, time::Duration}; +use async_stream::stream; use axum::{ extract::{Query, State}, - response::{sse::Event, Sse}, + response::{ + sse::{Event, KeepAlive}, + Sse, + }, }; use axum_session_auth::AuthSession; use axum_session_surreal::SessionSurrealPool; -use futures::{stream, Stream, StreamExt, TryStreamExt}; +use futures::{ + stream::{self, once}, + Stream, StreamExt, TryStreamExt, +}; use json_stream_parser::JsonStreamParser; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use serde_json::from_str; use surrealdb::{engine::any::Any, Surreal}; +use tokio::sync::{mpsc::channel, Mutex}; use tracing::{error, info}; use crate::{ @@ -19,7 +28,7 @@ use crate::{ create_chat_request, create_user_message, format_entities_json, LLMResponseFormat, }, }, - server::AppState, + server::{routes::html::render_template, AppState}, storage::{ db::{get_item, store_item, SurrealDbClient}, types::{ @@ -29,6 +38,7 @@ use crate::{ }, }; +// Error handling function fn create_error_stream( message: impl Into, ) -> Pin> + Send>> { @@ -127,8 +137,9 @@ pub async fn get_response_stream( }; // 5. Create channel for collecting complete response - let (tx, mut rx) = tokio::sync::mpsc::channel::(1000); + let (tx, mut rx) = channel::(1000); let tx_clone = tx.clone(); + let (tx_final, mut rx_final) = channel::>(1); // 6. Set up the collection task for DB storage let db_client = state.surreal_db_client.clone(); @@ -142,13 +153,15 @@ pub async fn get_response_stream( } // Try to extract structured data - if let Ok(response) = serde_json::from_str::(&full_json) { + if let Ok(response) = from_str::(&full_json) { let references: Vec = response .references .into_iter() .map(|r| r.reference) .collect(); + let _ = tx_final.send(references.clone()).await; + let ai_message = Message::new( user_message.conversation_id, MessageRole::AI, @@ -176,7 +189,7 @@ pub async fn get_response_stream( }); // Create a shared state for tracking the JSON parsing - let json_state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamParserState::new())); + let json_state = Arc::new(Mutex::new(StreamParserState::new())); // 7. Create the response event stream let event_stream = openai_stream @@ -185,7 +198,7 @@ pub async fn get_response_stream( let tx_storage = tx_clone.clone(); let json_state = json_state.clone(); - async move { + stream! { match result { Ok(response) => { let content = response @@ -200,29 +213,71 @@ pub async fn get_response_stream( // Process through JSON parser let mut state = json_state.lock().await; - let display_content = state.process_chunk(&content); drop(state); if !display_content.is_empty() { - return Ok(Event::default() + yield Ok(Event::default() .event("chat_message") .data(display_content)); } - - // Empty or filtered content - Ok(Event::default().event("chat_message").data("")) - } else { - Ok(Event::default().event("chat_message").data("")) + // If display_content is empty, don't yield anything } + // If content is empty, don't yield anything + } + Err(e) => { + yield Ok(Event::default() + .event("error") + .data(format!("Stream error: {}", e))); } - Err(e) => Ok(Event::default() - .event("error") - .data(format!("Stream error: {}", e))), } } }) - .buffered(10) - .chain(stream::once(async { + .flatten() + .chain(stream::once(async move { + if let Some(references) = rx_final.recv().await { + // Don't send any event if references is empty + if references.is_empty() { + return Ok(Event::default().event("empty")); // This event won't be sent + } + + // Prepare data for template + #[derive(Serialize)] + struct ReferenceData { + references: Vec, + user_message_id: String, + } + + // Render template with references + match render_template( + "chat/reference_list.html", + ReferenceData { + references, + user_message_id: user_message.id, + }, + state.templates.clone(), + ) { + Ok(html) => { + // Extract the String from Html + let html_string = html.0; // Convert Html to String + + // Return the rendered HTML + Ok(Event::default().event("references").data(html_string)) + } + Err(_) => { + // Handle template rendering error + Ok(Event::default() + .event("error") + .data("Failed to render references")) + } + } + } else { + // Handle case where no references were received + Ok(Event::default() + .event("error") + .data("Failed to retrieve references")) + } + })) + .chain(once(async { Ok(Event::default() .event("close_stream") .data("Stream complete")) @@ -230,7 +285,7 @@ pub async fn get_response_stream( info!("OpenAI streaming started"); Sse::new(event_stream.boxed()).keep_alive( - axum::response::sse::KeepAlive::new() + KeepAlive::new() .interval(Duration::from_secs(15)) .text("keep-alive"), ) @@ -259,7 +314,6 @@ impl StreamParserState { } // Get the current state of the JSON - // The get_result() method returns a &Value, not a Result let json = self.parser.get_result(); // Check if we're in the answer field @@ -283,46 +337,3 @@ impl StreamParserState { String::new() } } - -// 7. Create the response event stream -// let event_stream = openai_stream -// .map_err(|e| Box::new(e) as Box) -// .map(move |result| { -// let tx = tx_clone.clone(); -// async move { -// match result { -// Ok(response) => { -// let content = response -// .choices -// .first() -// .and_then(|choice| choice.delta.content.clone()) -// .unwrap_or_default(); - -// if !content.is_empty() { -// let _ = tx.send(content.clone()).await; -// Ok(Event::default().event("chat_message").data(content)) -// } else { -// Ok(Event::default().event("chat_message").data("")) -// } -// } -// Err(e) => Ok(Event::default() -// .event("error") -// .data(format!("Stream error: {}", e))), -// } -// } -// }) -// .buffered(10) -// .chain(stream::once(async { -// Ok(Event::default() -// .event("close_stream") -// .data("Stream complete")) -// })); - -// info!("OpenAI streaming started"); - -// Sse::new(event_stream.boxed()).keep_alive( -// axum::response::sse::KeepAlive::new() -// .interval(Duration::from_secs(15)) -// .text("keep-alive"), -// ) -// } diff --git a/templates/chat/reference_list.html b/templates/chat/reference_list.html new file mode 100644 index 0000000..ca8263b --- /dev/null +++ b/templates/chat/reference_list.html @@ -0,0 +1,30 @@ +
+ + +
+ \ No newline at end of file diff --git a/templates/chat/streaming_response.html b/templates/chat/streaming_response.html index 0591dd6..0548063 100644 --- a/templates/chat/streaming_response.html +++ b/templates/chat/streaming_response.html @@ -1,14 +1,16 @@
-
User
{{user_message.content}}
-
AI
-
- +
+
+ +
+