From 64daa77c6bed84eee944317bef1895753ce9ef8e Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Sun, 31 Aug 2025 18:50:25 -0400 Subject: [PATCH] leptos chat ui renders --- .github/workflows/release.yml | 7 +- Cargo.lock | 5 + crates/chat-ui/Cargo.toml | 5 + crates/chat-ui/src/app.rs | 305 ++++++++++++++++++++++++++++++++- crates/chat-ui/style/main.scss | 226 +++++++++++++++++++++++- 5 files changed, 536 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2165019..3d21803 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,12 @@ jobs: - name: Add target run: rustup target add ${{ matrix.target }} - - name: Build binary + - name: Build UI + run: cargo install --locked cargo-leptos && cd crates/chat-ui && cargo leptos build --release + env: + CARGO_TERM_COLOR: always + + - name: Build Binary run: cargo build --release --target ${{ matrix.target }} -p predict-otron-9000 -p cli env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index 5cbc50a..48bb7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,12 +827,17 @@ version = "0.1.0" dependencies = [ "axum", "console_error_panic_hook", + "gloo-net", "leptos", "leptos_axum", "leptos_meta", "leptos_router", + "reqwest", + "serde", + "serde_json", "tokio", "wasm-bindgen", + "web-sys", ] [[package]] diff --git a/crates/chat-ui/Cargo.toml b/crates/chat-ui/Cargo.toml index 4958398..16ade35 100644 --- a/crates/chat-ui/Cargo.toml +++ b/crates/chat-ui/Cargo.toml @@ -15,6 +15,11 @@ leptos_axum = { version = "0.8.0", optional = true } leptos_meta = { version = "0.8.0" } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } wasm-bindgen = { version = "=0.2.100", optional = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12", features = ["json"] } +web-sys = { version = "0.3", features = ["console"] } +gloo-net = { version = "0.6", features = ["http"] } [features] hydrate = [ diff --git a/crates/chat-ui/src/app.rs b/crates/chat-ui/src/app.rs index 63abee9..43dd2a5 100644 --- a/crates/chat-ui/src/app.rs +++ b/crates/chat-ui/src/app.rs @@ -47,6 +47,117 @@ use leptos_router::{ components::{Route, Router, Routes}, StaticSegment, }; +use serde::{Deserialize, Serialize}; +use gloo_net::http::Request; +use web_sys::console; +// Remove spawn_local import as we'll use different approach + +// Data structures for OpenAI-compatible API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct ChatRequest { + pub model: String, + pub messages: Vec, + pub max_tokens: Option, + pub stream: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChoice { + pub message: ChatMessage, + pub index: u32, + pub finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, +} + +// Data structures for models API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub object: String, + pub created: u64, + pub owned_by: String, +} + +#[derive(Debug, Deserialize)] +pub struct ModelsResponse { + pub object: String, + pub data: Vec, +} + +// API client function to fetch available models +pub async fn fetch_models() -> Result, String> { + let response = Request::get("/v1/models") + .send() + .await + .map_err(|e| format!("Failed to fetch models: {:?}", e))?; + + if response.ok() { + let models_response: ModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse models response: {:?}", e))?; + Ok(models_response.data) + } else { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(format!("Failed to fetch models {}: {}", status, error_text)) + } +} + +// API client function to send chat completion requests +pub async fn send_chat_completion(messages: Vec, model: String) -> Result { + let request = ChatRequest { + model, + messages, + max_tokens: Some(1024), + stream: Some(false), + }; + + let response = Request::post("/v1/chat/completions") + .header("Content-Type", "application/json") + .json(&request) + .map_err(|e| format!("Failed to create request: {:?}", e))? + .send() + .await + .map_err(|e| format!("Failed to send request: {:?}", e))?; + + if response.ok() { + let chat_response: ChatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {:?}", e))?; + + if let Some(choice) = chat_response.choices.first() { + Ok(choice.message.content.clone()) + } else { + Err("No response choices available".to_string()) + } + } else { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(format!("Server error {}: {}", status, error_text)) + } +} pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -77,28 +188,204 @@ pub fn App() -> impl IntoView { // sets the document title - + <Title text="Predict-Otron-9000 Chat"/> // content for this welcome page <Router> <main> <Routes fallback=|| "Page not found.".into_view()> - <Route path=StaticSegment("") view=HomePage/> + <Route path=StaticSegment("") view=ChatPage/> </Routes> </main> </Router> } } -/// Renders the home page of your application. +/// Renders the chat interface page #[component] -fn HomePage() -> impl IntoView { - // Creates a reactive value to update the button - let count = RwSignal::new(0); - let on_click = move |_| *count.write() += 1; +fn ChatPage() -> impl IntoView { + // State for conversation messages + let messages = RwSignal::new(Vec::<ChatMessage>::new()); + + // State for current user input + let input_text = RwSignal::new(String::new()); + + // State for loading indicator + let is_loading = RwSignal::new(false); + + // State for error messages + let error_message = RwSignal::new(Option::<String>::None); + + // State for available models and selected model + let available_models = RwSignal::new(Vec::<ModelInfo>::new()); + let selected_model = RwSignal::new(String::from("gemma-3-1b-it")); // Default model + + // Client-side only: Fetch models on component mount + #[cfg(target_arch = "wasm32")] + { + use leptos::task::spawn_local; + spawn_local(async move { + match fetch_models().await { + Ok(models) => { + available_models.set(models); + } + Err(error) => { + console::log_1(&format!("Failed to fetch models: {}", error).into()); + error_message.set(Some(format!("Failed to load models: {}", error))); + } + } + }); + } + + // Shared logic for sending a message + let send_message_logic = move || { + let user_input = input_text.get(); + if user_input.trim().is_empty() { + return; + } + + // Add user message to conversation + let user_message = ChatMessage { + role: "user".to_string(), + content: user_input.clone(), + }; + + messages.update(|msgs| msgs.push(user_message.clone())); + input_text.set(String::new()); + is_loading.set(true); + error_message.set(None); + + // Client-side only: Send chat completion request + #[cfg(target_arch = "wasm32")] + { + use leptos::task::spawn_local; + + // Prepare messages for API call + let current_messages = messages.get(); + let current_model = selected_model.get(); + + // Spawn async task to call API + spawn_local(async move { + match send_chat_completion(current_messages, current_model).await { + Ok(response_content) => { + let assistant_message = ChatMessage { + role: "assistant".to_string(), + content: response_content, + }; + messages.update(|msgs| msgs.push(assistant_message)); + is_loading.set(false); + } + Err(error) => { + console::log_1(&format!("API Error: {}", error).into()); + error_message.set(Some(error)); + is_loading.set(false); + } + } + }); + } + }; + + // Button click handler + let on_button_click = { + let send_logic = send_message_logic.clone(); + move |_: web_sys::MouseEvent| { + send_logic(); + } + }; + + // Handle enter key press in input field + let on_key_down = move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Enter" && !ev.shift_key() { + ev.prevent_default(); + send_message_logic(); + } + }; view! { - <h1>"Welcome to Leptos!"</h1> - <button on:click=on_click>"Click Me: " {count}</button> + <div class="chat-container"> + <div class="chat-header"> + <h1>"Predict-Otron-9000 Chat"</h1> + <div class="model-selector"> + <label for="model-select">"Model:"</label> + <select + id="model-select" + prop:value=move || selected_model.get() + on:change=move |ev| { + let new_model = event_target_value(&ev); + selected_model.set(new_model); + } + > + <For + each=move || available_models.get().into_iter() + key=|model| model.id.clone() + children=move |model| { + view! { + <option value=model.id.clone()> + {format!("{} ({})", model.id, model.owned_by)} + </option> + } + } + /> + </select> + </div> + </div> + + <div class="chat-messages"> + <For + each=move || messages.get().into_iter().enumerate() + key=|(i, _)| *i + children=move |(_, message)| { + let role_class = if message.role == "user" { "user-message" } else { "assistant-message" }; + view! { + <div class=format!("message {}", role_class)> + <div class="message-role">{message.role.clone()}</div> + <div class="message-content">{message.content.clone()}</div> + </div> + } + } + /> + + {move || { + if is_loading.get() { + view! { + <div class="message assistant-message loading"> + <div class="message-role">"assistant"</div> + <div class="message-content">"Thinking..."</div> + </div> + }.into_any() + } else { + view! {}.into_any() + } + }} + </div> + + {move || { + if let Some(error) = error_message.get() { + view! { + <div class="error-message"> + "Error: " {error} + </div> + }.into_any() + } else { + view! {}.into_any() + } + }} + + <div class="chat-input"> + <textarea + placeholder="Type your message here... (Press Enter to send, Shift+Enter for new line)" + prop:value=move || input_text.get() + on:input=move |ev| input_text.set(event_target_value(&ev)) + on:keydown=on_key_down + class:disabled=move || is_loading.get() + /> + <button + on:click=on_button_click + class:disabled=move || is_loading.get() || input_text.get().trim().is_empty() + > + "Send" + </button> + </div> + </div> } } diff --git a/crates/chat-ui/style/main.scss b/crates/chat-ui/style/main.scss index e4538e1..297e93f 100644 --- a/crates/chat-ui/style/main.scss +++ b/crates/chat-ui/style/main.scss @@ -1,4 +1,226 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + body { - font-family: sans-serif; - text-align: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + height: 100vh; + overflow: hidden; +} + +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 800px; + margin: 0 auto; + background-color: white; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); +} + +.chat-header { + background-color: #000000; + color: white; + padding: 1rem; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + gap: 1rem; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + } + + .model-selector { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + + label { + font-weight: 500; + font-size: 0.9rem; + } + + select { + background-color: white; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + font-family: inherit; + cursor: pointer; + min-width: 200px; + + &:focus { + outline: none; + border-color: #663c99; + box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.2); + } + + option { + padding: 0.5rem; + } + } + } +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + background-color: #fafafa; +} + +.message { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border-radius: 12px; + max-width: 80%; + word-wrap: break-word; + + &.user-message { + align-self: flex-end; + background-color: #2563eb; + color: white; + + .message-role { + font-weight: 600; + font-size: 0.8rem; + opacity: 0.8; + text-transform: uppercase; + } + + .message-content { + line-height: 1.5; + } + } + + &.assistant-message { + align-self: flex-start; + background-color: #646873; + border: 1px solid #e5e7eb; + color: #f3f3f3; + + .message-role { + font-weight: 600; + font-size: 0.8rem; + color: #c4c5cd; + text-transform: uppercase; + } + + .message-content { + line-height: 1.5; + } + + &.loading { + background-color: #f3f4f6; + border-color: #d1d5db; + + .message-content { + font-style: italic; + color: #6b7280; + } + } + } +} + +.error-message { + background-color: #fef2f2; + border: 1px solid #fca5a5; + color: #dc2626; + padding: 1rem; + margin: 0 1rem; + border-radius: 8px; + text-align: center; + font-weight: 500; +} + +.chat-input { + display: flex; + gap: 0.5rem; + padding: 1rem; + background-color: white; + border-top: 1px solid #e5e7eb; + + textarea { + flex: 1; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + resize: none; + min-height: 60px; + max-height: 120px; + font-family: inherit; + font-size: 1rem; + line-height: 1.5; + + &:focus { + outline: none; + border-color: #663c99; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &.disabled { + background-color: #f9fafb; + color: #6b7280; + cursor: not-allowed; + } + } + + button { + padding: 0.75rem 1.5rem; + background-color: #663c99; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + align-self: flex-end; + + &:hover:not(.disabled) { + background-color: #663c99; + } + + &.disabled { + background-color: #9ca3af; + cursor: not-allowed; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.3); + } + } +} + +/* Scrollbar styling for webkit browsers */ +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; } \ No newline at end of file