mirror of
https://github.com/geoffsee/predict-otron-9001.git
synced 2025-09-08 22:46:44 +00:00
leptos chat ui renders
This commit is contained in:
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -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]]
|
||||
|
@@ -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 = [
|
||||
|
@@ -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<ChatMessage>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub stream: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatChoice {
|
||||
pub message: ChatMessage,
|
||||
pub index: u32,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
// 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<ModelInfo>,
|
||||
}
|
||||
|
||||
// API client function to fetch available models
|
||||
pub async fn fetch_models() -> Result<Vec<ModelInfo>, 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<ChatMessage>, model: String) -> Result<String, String> {
|
||||
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 {
|
||||
<Stylesheet id="leptos" href="/pkg/chat-ui.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="Welcome to Leptos"/>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,226 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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;
|
||||
}
|
Reference in New Issue
Block a user