diff --git a/crates/daemon/src/chatpersistence.rs b/crates/daemon/src/chatpersistence.rs index 250aa81..97ecf3d 100644 --- a/crates/daemon/src/chatpersistence.rs +++ b/crates/daemon/src/chatpersistence.rs @@ -7,7 +7,7 @@ use tokio::fs; use tonic::async_trait; #[derive(Debug, sqlx::FromRow)] -pub struct ChatMessage { +pub struct ChatMessageData { pub id: i64, pub text: String, pub is_user: bool, @@ -15,8 +15,8 @@ pub struct ChatMessage { #[async_trait] pub trait ChatRepository { - async fn save_message(&self, text: &str, is_user: &bool) -> Result; - async fn get_latest_messages(&self) -> Result>; + async fn save_message(&self, text: &str, is_user: &bool) -> Result; + async fn get_latest_messages(&self) -> Result>; } pub struct SqliteChatRepository { @@ -51,8 +51,8 @@ impl SqliteChatRepository { #[async_trait] impl ChatRepository for SqliteChatRepository { - async fn save_message(&self, text: &str, is_user: &bool) -> Result { - let result = sqlx::query_as::<_, ChatMessage>( + async fn save_message(&self, text: &str, is_user: &bool) -> Result { + let result = sqlx::query_as::<_, ChatMessageData>( r#" INSERT INTO messages (text, is_user) VALUES (?, ?) @@ -67,7 +67,7 @@ impl ChatRepository for SqliteChatRepository { Ok(result) } - async fn get_latest_messages(&self) -> Result> { + async fn get_latest_messages(&self) -> Result> { let rows = sqlx::query( r#" SELECT * FROM ( @@ -83,7 +83,7 @@ impl ChatRepository for SqliteChatRepository { let messages = rows .into_iter() - .map(|row| ChatMessage { + .map(|row| ChatMessageData { id: row.get(0), text: row.get(1), is_user: row.get(2), diff --git a/crates/daemon/src/daemongrpc.rs b/crates/daemon/src/daemongrpc.rs index d3e0940..fb92264 100644 --- a/crates/daemon/src/daemongrpc.rs +++ b/crates/daemon/src/daemongrpc.rs @@ -1,4 +1,7 @@ -use crate::chatpersistence::{ChatMessage, ChatRepository}; +use crate::chatpersistence::{ChatMessageData, ChatRepository}; +use anyhow::Result; +use genai::chat::{ChatMessage, ChatRequest}; +use genai::Client; use shared::ai::ai_daemon_server::AiDaemon; use shared::ai::{ ChatHistoryRequest, ChatHistoryResponse, ChatMessage as CMessage, ChatRequest as CRequest, @@ -9,11 +12,15 @@ use tonic::{Code, Request, Response, Status}; pub struct DaemonServer { repo: Arc, + client: Client, } impl DaemonServer { - pub fn new(repo: Arc) -> Self { - Self { repo } + pub fn new(repo: Arc, client: Client) -> Self { + Self { + repo: repo, + client: client, + } } } @@ -21,6 +28,17 @@ impl DaemonServer { impl AiDaemon for DaemonServer { async fn chat(&self, request: Request) -> Result, Status> { let r = request.into_inner(); + let mut messages = gather_history(self.repo.clone()) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?; + messages.push(ChatMessage::user(r.text())); + let model = "llama3.2:latest"; + let response = self + .client + .exec_chat(model, ChatRequest::new(messages), None) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?; + let user_message = message_to_dto( &self .repo @@ -28,11 +46,18 @@ impl AiDaemon for DaemonServer { .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?, ); - let response_text = format!("Pong: {}", r.text()); + let response_text = match response.first_text() { + Some(t) => t, + None => "[No response from AI]", + }; + + println!("User: {}", r.text()); + println!("AI: {}", response_text.clone()); + let ai_message = message_to_dto( &self .repo - .save_message(response_text.as_str(), &false) + .save_message(response_text, &false) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?, ); @@ -73,10 +98,21 @@ impl AiDaemon for DaemonServer { } } -pub fn message_to_dto(msg: &ChatMessage) -> CMessage { +pub fn message_to_dto(msg: &ChatMessageData) -> CMessage { CMessage { id: msg.id, text: msg.text.clone(), is_user: msg.is_user, } } + +async fn gather_history(repo: Arc) -> Result> { + let messages = repo.get_latest_messages().await?; + Ok(messages + .iter() + .map(|m| match m.is_user { + true => ChatMessage::assistant(m.text.clone()), + false => ChatMessage::user(m.text.clone()), + }) + .collect()) +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 4bca865..8a643f7 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -29,9 +29,11 @@ async fn prompt_ollama( async fn main() -> Result<(), Box> { let chat_repo = SqliteChatRepository::new().await?; + let client = Client::default(); + let addr_s = "[::1]:50051"; let addr = addr_s.parse().unwrap(); - let daemon = DaemonServer::new(Arc::new(chat_repo)); + let daemon = DaemonServer::new(Arc::new(chat_repo), client); let reflection_service = tonic_reflection::server::Builder::configure() .register_encoded_file_descriptor_set(shared::ai::FILE_DESCRIPTOR_SET) .build_v1()?; diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 24e448a..8af852d 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,17 +1,19 @@ use std::time::Duration; use feshared::daemon::DaemonState; -use leptos::logging::log; use leptos::{prelude::*, reactive::spawn_local}; use leptos_router::{ components::{Route, Router, Routes}, path, }; -use serde::{Deserialize, Serialize}; -use wasm_bindgen::{prelude::Closure, JsValue}; +use wasm_bindgen::JsValue; -use crate::bridge::{invoke, listen}; use crate::popup::PopupView; +use crate::{bridge::invoke, components::DarkModeToggle}; +use crate::{ + bridge::invoke_typed, + components::{DaemonProvider, ThemeProvider}, +}; pub const BTN_PRIMARY: &str = "bg-slate-300 hover:bg-slate-400 dark:bg-slate-800 hover:dark-bg-slate-700 px-4 py-2 rounded-md"; @@ -35,18 +37,11 @@ fn Dashboard() -> impl IntoView { invoke("toggle_popup", empty_args).await; }); }; - - let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { - spawn_local(async { - let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; - }); - }; view! {
-
@@ -56,22 +51,6 @@ fn Dashboard() -> impl IntoView { } } -#[component] -pub fn DarkModeToggle() -> impl IntoView { - let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { - spawn_local(async { - let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; - }); - }; - view! { -
- - - -
- } -} - #[component] pub fn DaemonStatusIndicator() -> impl IntoView { let (poll_count, set_pool_count) = signal(0); @@ -81,8 +60,7 @@ pub fn DaemonStatusIndicator() -> impl IntoView { ); let status = LocalResource::new(move || async move { poll_count.get(); - let val = invoke("daemon_state", JsValue::NULL).await; - let s: DaemonState = serde_wasm_bindgen::from_value(val).unwrap(); + let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; s }); @@ -115,82 +93,3 @@ pub fn DaemonStatusIndicator() -> impl IntoView {
} } - -#[component] -pub fn DaemonProvider(children: ChildrenFn) -> impl IntoView { - let (poll_count, set_pool_count) = signal(0); - set_interval( - move || set_pool_count.update(|v| *v += 1), - Duration::from_secs(1), - ); - let status_res = LocalResource::new(move || async move { - poll_count.get(); - let val = invoke("daemon_state", JsValue::NULL).await; - let s: DaemonState = serde_wasm_bindgen::from_value(val).unwrap(); - s - }); - - let is_daemon_ok = Memo::new(move |_| status_res.get().map(|s| (s.is_ok, s.error))); - - provide_context(status_res); - - move || match is_daemon_ok.get() { - Some((true, _)) => children().into_any(), - Some((false, err)) => view! { }.into_any(), - None => view! {

Connecting...

}.into_any(), - } -} - -#[component] -fn DaemonErrorStatus(error: Option) -> impl IntoView { - view! { - -
-

{ error.unwrap_or("Daemon error!".to_string()) }

-
-
- } -} - -#[derive(Deserialize, Serialize, Clone)] -struct DarkMode { - is_dark_mode: bool, -} - -#[component] -pub fn ThemeProvider(children: Children) -> impl IntoView { - let (is_dark, set_dark) = signal(false); - - Effect::new(move |_| { - spawn_local(async move { - let handler = Closure::wrap(Box::new(move |evt: JsValue| { - log!("Received!!!"); - #[derive(Deserialize)] - struct TauriEvent { - payload: T, - } - if let Ok(wrapper) = serde_wasm_bindgen::from_value::>(evt) { - set_dark.set(wrapper.payload.is_dark_mode); - } - }) as Box); - let unlisten = listen("dark-mode-changed", &handler).await; - // TODO use on_cleanup to call the unlisten JS function. - handler.forget() - }); - }); - - Effect::new(move |_| { - let el = document() - .document_element() - .expect("HTML element not found!"); - if is_dark.get() { - let _ = el.set_attribute("class", "dark"); - } else { - let _ = el.set_attribute("class", ""); - } - }); - - view! { - {children()} - } -} diff --git a/frontend/src/bridge.rs b/frontend/src/bridge.rs index 6d6391f..0b065f2 100644 --- a/frontend/src/bridge.rs +++ b/frontend/src/bridge.rs @@ -1,3 +1,4 @@ +use serde::{de::DeserializeOwned, Deserialize}; use wasm_bindgen::prelude::*; #[wasm_bindgen] extern "C" { @@ -7,3 +8,29 @@ extern "C" { #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "event"])] pub async fn listen(event: &str, handler: &Closure) -> JsValue; } + +pub async fn invoke_typed(cmd: &str, args: JsValue) -> T +where + T: DeserializeOwned, +{ + let response = invoke(cmd, args).await; + let result: T = serde_wasm_bindgen::from_value(response).unwrap(); + result +} + +pub fn event_handler(callback: F) -> Closure +where + T: DeserializeOwned + 'static, + F: Fn(T) + 'static, +{ + Closure::new(move |val: JsValue| { + #[derive(Deserialize)] + struct TauriEvent { + payload: T, + } + + if let Ok(wrapper) = serde_wasm_bindgen::from_value::>(val) { + callback(wrapper.payload) + } + }) +} diff --git a/frontend/src/components.rs b/frontend/src/components.rs new file mode 100644 index 0000000..5eef11e --- /dev/null +++ b/frontend/src/components.rs @@ -0,0 +1,101 @@ +use std::time::Duration; + +use feshared::daemon::DaemonState; +use leptos::{component, prelude::*, reactive::spawn_local, view, IntoView}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsValue; + +use crate::bridge::{event_handler, invoke, invoke_typed, listen}; + +#[component] +pub fn DaemonProvider(children: ChildrenFn) -> impl IntoView { + let (poll_count, set_pool_count) = signal(0); + set_interval( + move || set_pool_count.update(|v| *v += 1), + Duration::from_secs(1), + ); + let status_res = LocalResource::new(move || async move { + poll_count.get(); + let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; + s + }); + + let is_daemon_ok = Memo::new(move |_| status_res.get().map(|s| (s.is_ok, s.error))); + + provide_context(status_res); + + move || match is_daemon_ok.get() { + Some((true, _)) => children().into_any(), + Some((false, err)) => view! { }.into_any(), + None => view! {

Connecting...

}.into_any(), + } +} + +#[component] +pub fn DaemonErrorStatus(error: Option) -> impl IntoView { + view! { +
+
+ + + +
+ { error.unwrap_or("Daemon error!".to_string()) } +
+ } +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct DarkMode { + is_dark_mode: bool, +} + +#[component] +pub fn ThemeProvider(children: Children) -> impl IntoView { + let (is_dark, set_dark) = signal(true); + + Effect::new(move |_| { + spawn_local(async move { + let hndlr = event_handler(move |mode: DarkMode| { + set_dark.set(mode.is_dark_mode); + }); + // TODO use on_cleanup to call the unlisten JS function. + let unlisten = listen("dark-mode-changed", &hndlr).await; + hndlr.forget() + }); + }); + + Effect::new(move |_| { + let el = document() + .document_element() + .expect("HTML element not found!"); + if is_dark.get() { + let _ = el.set_attribute("class", "dark"); + } else { + let _ = el.set_attribute("class", ""); + } + }); + + view! { + {children()} + } +} + +#[component] +pub fn DarkModeToggle() -> impl IntoView { + let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { + spawn_local(async { + let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; + }); + }; + view! { +
+ + + +
+ } +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 4c167f4..39849ed 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,5 +1,6 @@ mod app; mod bridge; +mod components; mod popup; use app::*; diff --git a/frontend/src/popup.rs b/frontend/src/popup.rs index b4a57cc..81fc671 100644 --- a/frontend/src/popup.rs +++ b/frontend/src/popup.rs @@ -1,6 +1,6 @@ use crate::{ - app::{DaemonProvider, DarkModeToggle, ThemeProvider}, - bridge::invoke, + bridge::{invoke, invoke_typed}, + components::{DaemonProvider, DarkModeToggle, ThemeProvider}, }; use feshared::{ chatmessage::{Message, MessageHistory}, @@ -14,7 +14,7 @@ use wasm_bindgen_futures::spawn_local; pub fn PopupView() -> impl IntoView { view! { - + } @@ -30,12 +30,11 @@ pub fn Popup() -> impl IntoView { use_context::>().expect("No daemon connection context!"); let init_history = Action::new_local(|(): &()| async move { - let response = invoke( + let history: MessageHistory = invoke_typed( "chat_history", serde_wasm_bindgen::to_value(&serde_json::json!({"chat_id": 1})).unwrap(), ) .await; - let history: MessageHistory = serde_wasm_bindgen::from_value(response).unwrap(); history }); Effect::new(move |prev_status: Option| { @@ -55,12 +54,11 @@ pub fn Popup() -> impl IntoView { let prompt_action = Action::new_local(|prompt: &String| { let prompt = prompt.clone(); async move { - let response = invoke( + let result: Vec = invoke_typed( "chat", serde_wasm_bindgen::to_value(&serde_json::json!({"prompt": prompt})).unwrap(), ) .await; - let result: Vec = serde_wasm_bindgen::from_value(response).unwrap(); result } }); diff --git a/frontend/styles.css b/frontend/styles.css index 36a4f07..3c05d59 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -588,9 +588,6 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } - .bottom-4 { - bottom: calc(var(--spacing) * 4); - } .isolate { isolation: isolate; } @@ -771,10 +768,6 @@ .field-sizing-fixed { field-sizing: fixed; } - .size-3 { - width: calc(var(--spacing) * 3); - height: calc(var(--spacing) * 3); - } .size-3\.5 { width: calc(var(--spacing) * 3.5); height: calc(var(--spacing) * 3.5); @@ -1281,9 +1274,6 @@ .justify-items-stretch { justify-items: stretch; } - .gap-1 { - gap: calc(var(--spacing) * 1); - } .gap-1\.5 { gap: calc(var(--spacing) * 1.5); } @@ -1963,9 +1953,6 @@ .py-\[0\.2rem\] { padding-block: 0.2rem; } - .pt-2 { - padding-top: calc(var(--spacing) * 2); - } .pr-8 { padding-right: calc(var(--spacing) * 8); } @@ -2151,6 +2138,9 @@ .text-white { color: var(--color-white); } + .text-zinc-100 { + color: var(--color-zinc-100); + } .text-zinc-950 { color: var(--color-zinc-950); } @@ -2831,6 +2821,11 @@ color: var(--color-white); } } + .dark\:text-zinc-800 { + &:where(.dark, .dark *) { + color: var(--color-zinc-800); + } + } .\[\&_svg\]\:pointer-events-none { & svg { pointer-events: none;