nightshade_api/web/
chat.rs1use leptos::html;
4use leptos::prelude::*;
5
6#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum ChatRole {
9 User,
11 Assistant,
13 Thinking,
15 Tool,
17 Info,
19 Error,
21}
22
23impl ChatRole {
24 fn class(self) -> &'static str {
25 match self {
26 ChatRole::User => "user",
27 ChatRole::Assistant => "assistant",
28 ChatRole::Thinking => "thinking",
29 ChatRole::Tool => "tool",
30 ChatRole::Info => "info",
31 ChatRole::Error => "error",
32 }
33 }
34}
35
36#[derive(Clone)]
39pub struct ChatMessage {
40 pub id: usize,
42 pub role: ChatRole,
44 pub text: String,
46}
47
48impl ChatMessage {
49 pub fn new(id: usize, role: ChatRole, text: impl Into<String>) -> Self {
51 Self {
52 id,
53 role,
54 text: text.into(),
55 }
56 }
57}
58
59#[component]
65pub fn Chat(
66 #[prop(into)] messages: Signal<Vec<ChatMessage>>,
67 on_send: Callback<String>,
68 #[prop(into, optional)] busy: Signal<bool>,
69 #[prop(into, optional)] connected: Signal<bool>,
70 #[prop(optional)] on_reset: Option<Callback<()>>,
71 #[prop(into, optional)] placeholder: String,
72) -> impl IntoView {
73 let draft = RwSignal::new(String::new());
74 let body_ref = NodeRef::<html::Div>::new();
75 let placeholder = if placeholder.is_empty() {
76 "Message…".to_string()
77 } else {
78 placeholder
79 };
80
81 Effect::new(move |_| {
82 let _ = messages.get();
83 if let Some(body) = body_ref.get() {
84 body.set_scroll_top(body.scroll_height());
85 }
86 });
87
88 let submit = move || {
89 let text = draft.get_untracked().trim().to_string();
90 if !text.is_empty() {
91 on_send.run(text);
92 draft.set(String::new());
93 }
94 };
95
96 view! {
97 <div class="nightshade-chat">
98 <div class="nightshade-chat-head">
99 <span class="nightshade-chat-status" class:online=move || connected.get()></span>
100 <span class="nightshade-chat-status-text">
101 {move || if connected.get() { "Connected" } else { "Offline" }}
102 </span>
103 <span class="nightshade-chat-spacer"></span>
104 {on_reset
105 .map(|callback| {
106 view! {
107 <button class="nightshade-chat-reset" on:click=move |_| callback.run(())>
108 "New"
109 </button>
110 }
111 })}
112 </div>
113 <div class="nightshade-chat-body" node_ref=body_ref>
114 <For each=move || messages.get() key=|message| message.id let:message>
115 <div class=format!("nightshade-chat-message {}", message.role.class())>
116 {message.text.clone()}
117 </div>
118 </For>
119 <Show when=move || busy.get() fallback=|| ()>
120 <div class="nightshade-chat-busy">
121 <span class="nightshade-spinner"></span>
122 "Working…"
123 </div>
124 </Show>
125 </div>
126 <div class="nightshade-chat-compose">
127 <textarea
128 class="nightshade-chat-input"
129 placeholder=placeholder
130 prop:value=move || draft.get()
131 on:input=move |event| draft.set(event_target_value(&event))
132 on:keydown=move |event| {
133 if event.key() == "Enter" && !event.shift_key() {
134 event.prevent_default();
135 submit();
136 }
137 }
138 ></textarea>
139 <button
140 class="nightshade-button primary nightshade-chat-send"
141 disabled=move || draft.get().trim().is_empty()
142 on:click=move |_| submit()
143 >
144 "Send"
145 </button>
146 </div>
147 </div>
148 }
149}