feat(chat): added chat interface and connection to ollama #10
@@ -1884,6 +1884,44 @@ h6 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -- Chat Action Bar -- */
|
||||
.chat-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 24px 0;
|
||||
background-color: var(--bg-sidebar);
|
||||
}
|
||||
|
||||
.chat-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-action-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-card);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.chat-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-action-label {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* -- Chat Input Bar -- */
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
|
||||
65
src/components/chat_action_bar.rs
Normal file
65
src/components/chat_action_bar.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
|
||||
|
||||
/// Action bar displayed above the chat input with copy, share, and edit buttons.
|
||||
///
|
||||
/// Only visible when there is at least one message in the conversation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `on_copy` - Copies the last assistant response to the clipboard
|
||||
/// * `on_share` - Copies the full conversation as text to the clipboard
|
||||
/// * `on_edit` - Places the last user message back in the input for editing
|
||||
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
|
||||
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
|
||||
/// * `has_user_message` - Whether a user message exists (disables edit if not)
|
||||
#[component]
|
||||
pub fn ChatActionBar(
|
||||
on_copy: EventHandler<()>,
|
||||
on_share: EventHandler<()>,
|
||||
on_edit: EventHandler<()>,
|
||||
has_messages: bool,
|
||||
has_assistant_message: bool,
|
||||
has_user_message: bool,
|
||||
) -> Element {
|
||||
if !has_messages {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-action-bar",
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_assistant_message,
|
||||
title: "Copy last response",
|
||||
onclick: move |_| on_copy.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaCopy,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Copy" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
title: "Copy conversation",
|
||||
onclick: move |_| on_share.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaShareNodes,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Share" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_user_message,
|
||||
title: "Edit last message",
|
||||
onclick: move |_| on_edit.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaPenToSquare,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Edit" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app_shell;
|
||||
mod article_detail;
|
||||
mod card;
|
||||
mod chat_action_bar;
|
||||
mod chat_bubble;
|
||||
mod chat_input_bar;
|
||||
mod chat_message_list;
|
||||
@@ -20,6 +21,7 @@ mod tool_card;
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
pub use card::*;
|
||||
pub use chat_action_bar::*;
|
||||
pub use chat_bubble::*;
|
||||
pub use chat_input_bar::*;
|
||||
pub use chat_message_list::*;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::components::{ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar};
|
||||
use crate::components::{
|
||||
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
|
||||
};
|
||||
use crate::infrastructure::chat::{
|
||||
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
|
||||
list_chat_sessions, rename_chat_session, save_chat_message,
|
||||
@@ -16,7 +18,7 @@ pub fn ChatPage() -> Element {
|
||||
// ---- Signals ----
|
||||
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
||||
let input_text: Signal<String> = use_signal(String::new);
|
||||
let mut input_text: Signal<String> = use_signal(String::new);
|
||||
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
||||
let mut streaming_content: Signal<String> = use_signal(String::new);
|
||||
let mut selected_model: Signal<String> = use_signal(String::new);
|
||||
@@ -202,6 +204,77 @@ pub fn ChatPage() -> Element {
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Action bar state ----
|
||||
let has_messages = !messages.read().is_empty();
|
||||
let has_assistant_message = messages
|
||||
.read()
|
||||
.iter()
|
||||
.any(|m| m.role == ChatRole::Assistant);
|
||||
let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User);
|
||||
|
||||
// Copy last assistant response to clipboard
|
||||
let on_copy = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let last_assistant = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::Assistant)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_assistant {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Copy full conversation as text to clipboard
|
||||
let on_share = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let text: String = messages
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|m| m.role != ChatRole::System)
|
||||
.map(|m| {
|
||||
let label = match m.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => "System",
|
||||
};
|
||||
format!("{label}:\n{}\n", m.content)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Edit last user message: remove it and place text back in input
|
||||
let on_edit = move |_: ()| {
|
||||
let last_user = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::User)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_user {
|
||||
// Remove the last user message (and any assistant reply after it)
|
||||
let mut msgs = messages.read().clone();
|
||||
if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) {
|
||||
msgs.truncate(pos);
|
||||
messages.set(msgs);
|
||||
}
|
||||
input_text.set(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to bottom when messages or streaming content changes
|
||||
let msg_count = messages.read().len();
|
||||
let stream_len = streaming_content.read().len();
|
||||
@@ -244,6 +317,14 @@ pub fn ChatPage() -> Element {
|
||||
streaming_content: streaming_content.read().clone(),
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
ChatActionBar {
|
||||
on_copy: on_copy,
|
||||
on_share: on_share,
|
||||
on_edit: on_edit,
|
||||
has_messages: has_messages,
|
||||
has_assistant_message: has_assistant_message,
|
||||
has_user_message: has_user_message,
|
||||
}
|
||||
ChatInputBar {
|
||||
input_text: input_text,
|
||||
on_send: on_send,
|
||||
|
||||
Reference in New Issue
Block a user