From 41adcd844e9cc9634c1ff31426be55c6d5e3d93a Mon Sep 17 00:00:00 2001 From: V4LER11 Date: Mon, 9 Jun 2025 21:19:51 +0100 Subject: [PATCH] Added New Chat View --- crates/core/assets/new-chat.css | 65 +++++++++++++++ crates/core/src/chat_service.rs | 100 +++++++++++++++++++++++ crates/core/src/components/InputBox.rs | 26 ++++-- crates/core/src/components/NewChat.rs | 36 ++++++++ crates/core/src/components/SendButton.rs | 84 +++---------------- crates/core/src/components/mod.rs | 1 + crates/core/src/main.rs | 13 ++- crates/core/src/shared_state.rs | 13 +++ 8 files changed, 260 insertions(+), 78 deletions(-) create mode 100644 crates/core/assets/new-chat.css create mode 100644 crates/core/src/chat_service.rs create mode 100644 crates/core/src/components/NewChat.rs diff --git a/crates/core/assets/new-chat.css b/crates/core/assets/new-chat.css new file mode 100644 index 0000000..8b6f595 --- /dev/null +++ b/crates/core/assets/new-chat.css @@ -0,0 +1,65 @@ +@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap'); + + +.new-chat-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + top: 20vh; + width: calc(100% - var(--sidebar-width)); + left: var(--sidebar-width); + position: relative; + height: 40vh; + padding: var(--spacing-lg); + text-align: center; + /*background-color: #e2e1da;*/ + transition: width var(--transition-normal), left var(--transition-normal); +} + +.new-chat-box.sidebar-collapsed { + width: calc(100% - var(--sidebar-collapsed-width)); + left: var(--sidebar-collapsed-width); +} + +.new-chat-greeting { + color: #3d3d3a; + font-family: "Lora", serif; + font-weight: 400; + font-style: italic; + font-size: clamp(1rem, 4vw, 2.8rem); + letter-spacing: 0.02em; +} + +.textarea-box-new-chat { + width: 80vh; + margin: 0 auto; +} + +.textarea-wrapper-new-chat { + background-color: var(--color-white); + border: 0.5px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: 0 0.25rem var(--spacing-xl) var(--color-shadow); + padding: 15px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + z-index: 10; + cursor: text; + transition: all var(--transition-fast); + box-sizing: border-box; + flex-shrink: 0; +} + +.textarea-wrapper-new-chat textarea { + border: none; + font-family: var(--font-sans-serif); + font-size: 16px; + resize: none; + outline: none; + height: 30px; + background-color: transparent; + letter-spacing: -0.025em; +} diff --git a/crates/core/src/chat_service.rs b/crates/core/src/chat_service.rs new file mode 100644 index 0000000..2b1f98f --- /dev/null +++ b/crates/core/src/chat_service.rs @@ -0,0 +1,100 @@ +use dioxus::logger::tracing::info; +use dioxus::prelude::*; +use std::pin::pin; +use futures::StreamExt; + +use crate::chat_completions::comp_stream::test_stream_frontend; +use crate::chat_completions::comp_chunks_collector::extract_content_from_chunks; +use crate::openai; +use crate::shared_state::{Message, SharedState}; + + +#[derive(Clone)] +pub struct SendMessageRequest { + pub content: String, +} + +pub async fn message_service(mut rx: UnboundedReceiver) { + let sh = use_context::(); + + while let Some(request) = rx.next().await { + send_message_and_stream(sh.clone(), request.content).await; + } +} + +async fn send_message_and_stream(sh: SharedState, message_content: String) { + if message_content.trim().is_empty() { + return; + } + + let mut sh_messages = sh.messages.clone(); + let mut sh_scrolled_to_bottom_element = sh.scrolled_to_bottom_element.clone(); + + let assistant_message_index; + { + let mut messages = sh_messages.write(); + messages.push(Message { + role: "user".to_string(), + content: message_content, + }); + info!("Adding user message"); + sh_scrolled_to_bottom_element.set(false); + + // Create a new assistant message with empty content + assistant_message_index = messages.len(); + messages.push(Message { + role: "assistant".to_string(), + content: "".to_string(), + }); + info!("Adding empty assistant message"); + } + + let model = match sh.active_model.read().clone() { + Some(model) => model.id, + None => { + info!("No active model selected"); + return; + } + }; + + // Convert messages to OMessages for the API + let omessages = sh_messages.read().iter() + .filter(|m| m.role == "user" || m.role == "assistant") + .map(|m| openai::OMessage::new( + m.role.clone(), + m.content.clone(), + )) + .collect::>(); + // todo: remove the last empty user message + + info!("{:#?}", omessages); + info!("Starting streaming"); + + // Start streaming the response + match test_stream_frontend(omessages, model).await { + Ok(stream) => { + let mut received_chunks = Vec::new(); + + // Process each chunk as it arrives + let mut stream = pin!(stream); + while let Some(chunk) = stream.next().await { + received_chunks.push(chunk); + { + let mut messages = sh_messages.write(); + if let Some(assistant_message) = messages.get_mut(assistant_message_index) { + // Update with all chunks received so far + assistant_message.content = extract_content_from_chunks(&received_chunks); + } + } + } + info!("Stream completed. Received {} chunks in total.", received_chunks.len()); + }, + Err(e) => { + info!("Failed to start stream: {}", e); + let mut messages = sh_messages.write(); + if let Some(assistant_message) = messages.get_mut(assistant_message_index) { + assistant_message.content = format!("Error: {}", e); + } + } + } +} diff --git a/crates/core/src/components/InputBox.rs b/crates/core/src/components/InputBox.rs index 07c82bb..6d0b6d2 100644 --- a/crates/core/src/components/InputBox.rs +++ b/crates/core/src/components/InputBox.rs @@ -8,8 +8,15 @@ use crate::shared_state::SharedState; const INPUT_BOX_CSS: Asset = asset!("assets/input-box.css"); + +#[derive(Props, PartialEq, Clone)] +pub struct InputBoxProps { + #[props(default = false)] + pub for_new_chat: bool, +} + #[component] -pub fn InputBox() -> Element { +pub fn InputBox(props: InputBoxProps) -> Element { let sh = use_context::(); let mut chat_input_value = use_signal(|| "".to_string()); @@ -19,22 +26,29 @@ pub fn InputBox() -> Element { } else { "sidebar-collapsed" }; + + let (textarea_box_class, textarea_wrapper_class, textarea_placeholder) = if props.for_new_chat { + ("textarea-box-new-chat", "textarea-wrapper-new-chat", "How can I help you?") + } else { + ("textarea-box", "textarea-wrapper", "Type your message...") + }; rsx! { document::Link { rel: "stylesheet", href: INPUT_BOX_CSS} - div {class: "textarea-box {textarea_box_sidebar_classname}", - div {class: "textarea-wrapper", + div {class: "{textarea_box_class} {textarea_box_sidebar_classname}", + div {class: "{textarea_wrapper_class}", textarea { id: "chat-input", - value: "{chat_input_value}", // Use string interpolation + value: "{chat_input_value}", oninput: move |evt| chat_input_value.set(evt.data.value()), - placeholder: "Type your message..." + placeholder: "{textarea_placeholder}" } div {class: "sub-input-box-container", ModelSelector {} SendButton { - chat_input_value: chat_input_value.clone() + chat_input_value: chat_input_value.clone(), + for_new_chat: props.for_new_chat, } } } diff --git a/crates/core/src/components/NewChat.rs b/crates/core/src/components/NewChat.rs new file mode 100644 index 0000000..6861536 --- /dev/null +++ b/crates/core/src/components/NewChat.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::*; +use crate::components::InputBox::InputBox; +use crate::shared_state::{ActiveView, SharedState}; + +const NEW_CHAT_CSS: Asset = asset!("assets/new-chat.css"); + + +#[component] +pub fn NewChat() -> Element { + let sh = use_context::(); + let is_hidden = *sh.active_view.read() != ActiveView::NewChat; + if is_hidden { + return rsx! { div { style: "display: none;" } }; + } + + let new_chat_box_sidebar_classname = if sh.side_bar_state.read().is_expanded { + "" + } else { + "sidebar-collapsed" + }; + + rsx! { + document::Link { rel: "stylesheet", href: NEW_CHAT_CSS} + + div { class: "new-chat-box {new_chat_box_sidebar_classname}", + h1 { class: "new-chat-greeting {new_chat_box_sidebar_classname}", + "Good Afternoon!" + } + div { + InputBox { + for_new_chat: true, + } + } + } + } +} diff --git a/crates/core/src/components/SendButton.rs b/crates/core/src/components/SendButton.rs index ee421f9..c45ddc0 100644 --- a/crates/core/src/components/SendButton.rs +++ b/crates/core/src/components/SendButton.rs @@ -1,96 +1,38 @@ -use std::pin::pin; use dioxus::core_macro::{component, rsx, Props}; use dioxus::dioxus_core::Element; -use dioxus::hooks::use_context; -use dioxus::logger::tracing::info; use dioxus::prelude::*; -use crate::chat_completions::comp_stream::{test_stream_frontend}; -use crate::openai; -use crate::shared_state::{Message, SharedState}; -use futures::StreamExt; -use crate::chat_completions::comp_chunks_collector::extract_content_from_chunks; + +use crate::chat_service::SendMessageRequest; +use crate::shared_state::{ActiveView, SharedState}; + #[derive(Props, PartialEq, Clone)] pub struct SendButtonProps { pub chat_input_value: Signal, + #[props(default = false)] + pub for_new_chat: bool, } #[component] pub fn SendButton(props: SendButtonProps) -> Element { let mut sh = use_context::(); + let mut chat_input_value = props.chat_input_value.clone(); + let message_service = use_coroutine_handle::(); let button_on_click = move |_| { let value = chat_input_value.read().clone(); - + if value.trim().is_empty() { return; } - - let assistant_message_index; - { - let mut messages = sh.messages.write(); - messages.push( - Message { - role: "user".to_string(), - content: value, - } - ); - sh.scrolled_to_bottom_element.set(false); - - // Create a new assistant message with empty content - assistant_message_index = messages.len(); - messages.push( - Message { - role: "assistant".to_string(), - content: "".to_string(), - } - ); - } - - let model = sh.active_model.read().clone().unwrap().id; - - // Convert messages to OMessages for the API - let omessages = sh.messages.read().iter() - .filter(|m| m.role == "user" || m.role == "assistant") - .map(|m| openai::OMessage::new( - m.role.clone(), - m.content.clone(), - )) - .collect::>(); - - // Start streaming the response - spawn(async move { - match test_stream_frontend(omessages, model).await { - Ok(stream) => { - let mut received_chunks = Vec::new(); + chat_input_value.set("".to_string()); - // Process each chunk as it arrives - let mut stream = pin!(stream); - while let Some(chunk) = stream.next().await { - received_chunks.push(chunk); - { - let mut messages = sh.messages.write(); - if let Some(assistant_message) = messages.get_mut(assistant_message_index) { - // Update with all chunks received so far - assistant_message.content = extract_content_from_chunks(&received_chunks); - } - } - } - info!("Stream completed. Received {} chunks in total.", received_chunks.len()); - }, - Err(e) => { - info!("Failed to start stream: {}", e); - let mut messages = sh.messages.write(); - if let Some(assistant_message) = messages.get_mut(assistant_message_index) { - assistant_message.content = format!("Error: {}", e); - } - } - } + message_service.send(SendMessageRequest { + content: value, }); - // Clear the input after sending - chat_input_value.set("".to_string()); + sh.active_view.set(ActiveView::Chat); }; rsx! { diff --git a/crates/core/src/components/mod.rs b/crates/core/src/components/mod.rs index 4d20ee4..1636b04 100644 --- a/crates/core/src/components/mod.rs +++ b/crates/core/src/components/mod.rs @@ -3,3 +3,4 @@ pub mod SendButton; pub mod InputBox; pub mod ChatBody; pub mod SideBar; +pub mod NewChat; diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 102d6ea..9d7c0d0 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -6,13 +6,16 @@ mod openai; mod messages; mod image_utils; mod chat_completions; +pub mod chat_service; use dioxus::prelude::*; +use crate::chat_service::message_service; use crate::components::ChatBody::ChatBody; use crate::components::InputBox::InputBox; +use crate::components::NewChat::NewChat; use crate::components::SideBar::SideBar; use crate::models::model_polling_service; -use crate::shared_state::SharedState; +use crate::shared_state::{ActiveView, SharedState}; const FAVICON: Asset = asset!("/assets/favicon.ico"); @@ -27,6 +30,7 @@ fn main() { fn App() -> Element { let _sh = use_context_provider(|| SharedState::new()); use_coroutine(model_polling_service); + use_coroutine(message_service); rsx! { document::Link { rel: "icon", href: FAVICON } @@ -34,6 +38,7 @@ fn App() -> Element { div { class: "app", SideBar {}, MainContent {}, + NewChat {}, } } } @@ -41,6 +46,11 @@ fn App() -> Element { #[component] fn MainContent() -> Element { let sh = use_context::(); + + let is_hidden = *sh.active_view.read() != ActiveView::Chat; + if is_hidden { + return rsx! { div { style: "display: none;" } }; + } let main_content_sidebar_classname = if sh.side_bar_state.read().is_expanded { "" @@ -48,6 +58,7 @@ fn MainContent() -> Element { "sidebar-collapsed" }; + rsx! { div {class: "main-content {main_content_sidebar_classname}", PrimaryChat {}, diff --git a/crates/core/src/shared_state.rs b/crates/core/src/shared_state.rs index bf2eb6d..466af8d 100644 --- a/crates/core/src/shared_state.rs +++ b/crates/core/src/shared_state.rs @@ -14,6 +14,7 @@ pub struct SharedState { pub empty_space_height: Signal, pub message_container_bottom_element: Signal>>, pub scrolled_to_bottom_element: Signal, + pub active_view: Signal, pub side_bar_state: Signal, } @@ -28,6 +29,18 @@ impl SharedState { } } +#[derive(PartialEq)] +pub enum ActiveView { + NewChat, + Chat, +} + +impl Default for ActiveView { + fn default() -> Self { + ActiveView::NewChat + } +} + #[derive(Clone)] pub struct CompletionModelState { pub is_loading: bool,