diff --git a/crates/daemon/migrations/20260214_1_init.sql b/crates/daemon/migrations/20260214_1_init.sql new file mode 100644 index 0000000..8001e1d --- /dev/null +++ b/crates/daemon/migrations/20260214_1_init.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + is_user BOOL NOT NULL +); diff --git a/crates/daemon/src/chatpersistence.rs b/crates/daemon/src/chatpersistence.rs index 67c02d6..250aa81 100644 --- a/crates/daemon/src/chatpersistence.rs +++ b/crates/daemon/src/chatpersistence.rs @@ -1,6 +1,5 @@ use anyhow::Result; use directories::ProjectDirs; -use shared::ai::ChatMessage as CMessage; use sqlx::sqlite::SqliteConnectOptions; use sqlx::Row; use sqlx::SqlitePool; @@ -20,14 +19,6 @@ pub trait ChatRepository { async fn get_latest_messages(&self) -> Result>; } -pub fn message_to_dto(msg: &ChatMessage) -> CMessage { - CMessage { - id: msg.id, - text: msg.text.clone(), - is_user: msg.is_user, - } -} - pub struct SqliteChatRepository { pool: SqlitePool, } @@ -49,16 +40,10 @@ impl SqliteChatRepository { ) .await?; - sqlx::query( - "CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - text TEXT NOT NULL, - is_user BOOL NOT NULL - )", - ) - .execute(&pool) - .await - .inspect_err(|e| println!("sql error: {}", e))?; + sqlx::migrate!("./migrations") + .run(&pool) + .await + .inspect_err(|e| eprintln!("Migration failed! {}", e))?; Ok(Self { pool }) } diff --git a/crates/daemon/src/daemongrpc.rs b/crates/daemon/src/daemongrpc.rs new file mode 100644 index 0000000..d3e0940 --- /dev/null +++ b/crates/daemon/src/daemongrpc.rs @@ -0,0 +1,82 @@ +use crate::chatpersistence::{ChatMessage, ChatRepository}; +use shared::ai::ai_daemon_server::AiDaemon; +use shared::ai::{ + ChatHistoryRequest, ChatHistoryResponse, ChatMessage as CMessage, ChatRequest as CRequest, + ChatResponse as CResponse, DaemonStatusRequest, DaemonStatusResponse, +}; +use std::sync::Arc; +use tonic::{Code, Request, Response, Status}; + +pub struct DaemonServer { + repo: Arc, +} + +impl DaemonServer { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[tonic::async_trait] +impl AiDaemon for DaemonServer { + async fn chat(&self, request: Request) -> Result, Status> { + let r = request.into_inner(); + let user_message = message_to_dto( + &self + .repo + .save_message(r.text(), &true) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?, + ); + let response_text = format!("Pong: {}", r.text()); + let ai_message = message_to_dto( + &self + .repo + .save_message(response_text.as_str(), &false) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?, + ); + let response = CResponse { + chat_id: 1, + messages: vec![user_message, ai_message], + }; + return Ok(Response::new(response)); + } + + async fn chat_history( + &self, + _: Request, + ) -> Result, Status> { + let messages = self + .repo + .get_latest_messages() + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?; + + let response = ChatHistoryResponse { + chat_id: 1, + history: messages.iter().map(|m| message_to_dto(m)).collect(), + }; + Ok(Response::new(response)) + } + + async fn daemon_status( + &self, + _: Request, + ) -> Result, Status> { + let status = DaemonStatusResponse { + is_ok: true, + message: None, + error: None, + }; + Ok(Response::new(status)) + } +} + +pub fn message_to_dto(msg: &ChatMessage) -> CMessage { + CMessage { + id: msg.id, + text: msg.text.clone(), + is_user: msg.is_user, + } +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index b389ade..4bca865 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,94 +1,15 @@ mod chatpersistence; +mod daemongrpc; -use std::sync::atomic::AtomicI64; use std::sync::Arc; use genai::chat::{ChatMessage, ChatRequest}; use genai::Client; -use shared::ai::ai_daemon_server::{AiDaemon, AiDaemonServer}; -use shared::ai::{ - ChatHistoryRequest, ChatHistoryResponse, ChatMessage as CMessage, ChatRequest as CRequest, - ChatResponse as CResponse, PromptRequest, PromptResponse, -}; -use tonic::{transport::Server, Code, Request, Response, Status}; +use shared::ai::ai_daemon_server::AiDaemonServer; +use tonic::transport::Server; use chatpersistence::SqliteChatRepository; - -use crate::chatpersistence::{message_to_dto, ChatRepository}; - -pub struct DaemonServer { - message_counter: AtomicI64, - repo: Arc, -} - -impl DaemonServer { - pub fn new(repo: Arc) -> Self { - Self { - message_counter: AtomicI64::new(0), - repo, - } - } -} - -#[tonic::async_trait] -impl AiDaemon for DaemonServer { - async fn prompt( - &self, - request: Request, - ) -> Result, Status> { - let remote_a = request.remote_addr(); - let prompt_value = request.into_inner().prompt; - println!("Request from {:?}: {:?}", remote_a, prompt_value); - let client = Client::default(); - let response = prompt_ollama(&client, "llama3.2", prompt_value.as_str()) - .await - .unwrap_or_else(|err| format!("Prompt error: {}", err)); - println!("Respone: {}", response); - let reply = PromptResponse { response: response }; - Ok(Response::new(reply)) - } - - async fn chat(&self, request: Request) -> Result, Status> { - let r = request.into_inner(); - let user_message = message_to_dto( - &self - .repo - .save_message(r.text(), &true) - .await - .map_err(|e| Status::new(Code::Internal, e.to_string()))?, - ); - let response_text = format!("Pong: {}", r.text()); - let ai_message = message_to_dto( - &self - .repo - .save_message(response_text.as_str(), &false) - .await - .map_err(|e| Status::new(Code::Internal, e.to_string()))?, - ); - let response = CResponse { - chat_id: 1, - messages: vec![user_message, ai_message], - }; - return Ok(Response::new(response)); - } - - async fn chat_history( - &self, - _: Request, - ) -> Result, Status> { - let messages = self - .repo - .get_latest_messages() - .await - .map_err(|e| Status::new(Code::Internal, e.to_string()))?; - - let response = ChatHistoryResponse { - chat_id: 1, - history: messages.iter().map(|m| message_to_dto(m)).collect(), - }; - Ok(Response::new(response)) - } -} +use daemongrpc::DaemonServer; async fn prompt_ollama( client: &Client, diff --git a/crates/feshared/Cargo.toml b/crates/feshared/Cargo.toml index 45ff87c..32d5a53 100644 --- a/crates/feshared/Cargo.toml +++ b/crates/feshared/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -serde = "1.0.228" +serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/feshared/src/lib.rs b/crates/feshared/src/lib.rs index 853602f..0524d1d 100644 --- a/crates/feshared/src/lib.rs +++ b/crates/feshared/src/lib.rs @@ -14,3 +14,14 @@ pub mod chatmessage { pub history: Vec, } } + +pub mod daemon { + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Deserialize, Serialize)] + pub struct DaemonState { + pub is_ok: bool, + pub message: Option, + pub error: Option, + } +} diff --git a/crates/shared/proto/api.proto b/crates/shared/proto/api.proto index a27209b..b4cf51c 100644 --- a/crates/shared/proto/api.proto +++ b/crates/shared/proto/api.proto @@ -2,9 +2,9 @@ syntax = "proto3"; package ai_daemon; service AiDaemon { - rpc Prompt(PromptRequest) returns (PromptResponse); rpc Chat(ChatRequest) returns (ChatResponse); rpc ChatHistory(ChatHistoryRequest) returns (ChatHistoryResponse); + rpc DaemonStatus(DaemonStatusRequest) returns (DaemonStatusResponse); } message ChatMessage { @@ -33,10 +33,10 @@ message ChatHistoryResponse { repeated ChatMessage history = 10; } -message PromptRequest { - string prompt = 1; -} +message DaemonStatusRequest {} -message PromptResponse { - string response = 1; +message DaemonStatusResponse { + bool is_ok = 1; + optional string message = 10; + optional string error = 20; } diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs new file mode 100644 index 0000000..5c9f9ed --- /dev/null +++ b/frontend/src-tauri/src/commands.rs @@ -0,0 +1,115 @@ +use tauri::{Emitter, Manager, State}; + +use feshared::{ + chatmessage::{Message, MessageHistory}, + daemon::DaemonState, +}; +use shared::ai::{ChatHistoryRequest, ChatRequest, DaemonStatusRequest}; + +use crate::AppState; + +#[tauri::command] +pub fn toggle_popup(app_handle: tauri::AppHandle) { + match app_handle.get_webview_window("popup") { + Some(window) => { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + window.hide().unwrap(); + } else { + window.show().unwrap(); + window.set_focus().unwrap(); + let _ = window.emit("window-focused", ()); + } + } + None => { + println!("ERROR: Window with label 'popup' not found!"); + } + } +} + +#[tauri::command] +pub async fn chat( + state: State<'_, AppState>, + prompt: String, + chat_id: Option, +) -> Result, String> { + let mut client = state.grpc_client.lock().await; + let request = tonic::Request::new(ChatRequest { + chat_id: chat_id, + text: Some(prompt), + }); + match client.chat(request).await { + Ok(response) => { + let r = response.into_inner(); + r.messages.iter().for_each(|m| { + if m.is_user { + println!(">>> {}", m.text) + } else { + println!("<<< {}", m.text) + } + }); + Ok(r.messages + .iter() + .map(|msg| Message { + id: msg.id, + text: msg.text.clone(), + is_user: msg.is_user, + }) + .collect()) + } + Err(e) => { + println!("gRPC error: {}", e); + Err(format!("gRPC error: {}", e)) + } + } +} + +#[tauri::command] +pub async fn chat_history( + state: State<'_, AppState>, + chat_id: Option, +) -> Result { + let mut client = state.grpc_client.lock().await; + let result = client + .chat_history(ChatHistoryRequest { chat_id: None }) + .await; + match result { + Ok(response) => { + let r = response.into_inner(); + Ok(MessageHistory { + chat_id: None, + history: r + .history + .iter() + .map(|m| Message { + id: m.id, + is_user: m.is_user, + text: m.text.clone(), + }) + .collect(), + }) + } + Err(e) => Err(format!("gRPC error: {e}")), + } +} + +#[tauri::command] +pub async fn daemon_state(state: State<'_, AppState>) -> Result { + let mut client = state.grpc_client.lock().await; + let result = client.daemon_status(DaemonStatusRequest {}).await; + match result { + Ok(status) => { + let status_inner = status.into_inner(); + Ok(DaemonState { + is_ok: status_inner.is_ok, + message: status_inner.message, + error: status_inner.error, + }) + } + Err(e) => Ok(DaemonState { + is_ok: false, + message: None, + error: Some(e.message().to_string()), + }), + } +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 4e362e0..e84dfad 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -1,112 +1,22 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use feshared::chatmessage::{Message, MessageHistory}; -use shared::ai::{ - ai_daemon_client::AiDaemonClient, ChatHistoryRequest, ChatRequest, PromptRequest, -}; -use tauri::{Emitter, Manager, State}; +mod commands; + use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; use tokio::sync::Mutex; -use tonic::{client, Response}; -struct AppState { +use commands::{chat, chat_history, daemon_state, toggle_popup}; +use shared::ai::ai_daemon_client::AiDaemonClient; + +pub struct AppState { grpc_client: Mutex>, current_chat: Mutex>, } -#[tauri::command] -fn toggle_popup(app_handle: tauri::AppHandle) { - match app_handle.get_webview_window("popup") { - Some(window) => { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - window.hide().unwrap(); - } else { - window.show().unwrap(); - window.set_focus().unwrap(); - let _ = window.emit("window-focused", ()); - } - } - None => { - println!("ERROR: Window with label 'popup' not found!"); - } - } -} - -#[tauri::command] -async fn chat( - state: State<'_, AppState>, - prompt: String, - chat_id: Option, -) -> Result, String> { - let mut client = state.grpc_client.lock().await; - let request = tonic::Request::new(ChatRequest { - chat_id: chat_id, - text: Some(prompt), - }); - match client.chat(request).await { - Ok(response) => { - let r = response.into_inner(); - r.messages.iter().for_each(|m| { - if m.is_user { - println!(">>> {}", m.text) - } else { - println!("<<< {}", m.text) - } - }); - Ok(r.messages - .iter() - .map(|msg| Message { - id: msg.id, - text: msg.text.clone(), - is_user: msg.is_user, - }) - .collect()) - } - Err(e) => { - println!("gRPC error: {}", e); - Err(format!("gRPC error: {}", e)) - } - } -} - -#[tauri::command] -async fn chat_history( - state: State<'_, AppState>, - chat_id: Option, -) -> Result { - let mut client = state.grpc_client.lock().await; - let result = client - .chat_history(ChatHistoryRequest { chat_id: None }) - .await; - match result { - Ok(response) => { - let r = response.into_inner(); - Ok(MessageHistory { - chat_id: None, - history: r - .history - .iter() - .map(|m| Message { - id: m.id, - is_user: m.is_user, - text: m.text.clone(), - }) - .collect(), - }) - } - Err(e) => Err(format!("gRPC error: {e}")), - } -} - #[tokio::main] async fn main() { - let channel = tonic::transport::Channel::from_static("http://[::1]:50051") - .connect() - .await - .expect("Could not connect to daemon!"); - + let channel = tonic::transport::Channel::from_static("http://[::1]:50051").connect_lazy(); let client = AiDaemonClient::new(channel); tauri::Builder::default() @@ -115,7 +25,12 @@ async fn main() { current_chat: Mutex::new(None), }) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) - .invoke_handler(tauri::generate_handler![toggle_popup, chat_history, chat,]) + .invoke_handler(tauri::generate_handler![ + toggle_popup, + chat_history, + chat, + daemon_state + ]) .setup(|app| { /* Auto-hide popup when focus is lost if let Some(window) = app.get_webview_window("popup") { diff --git a/frontend/src/app.rs b/frontend/src/app.rs index f8df2c2..8fa5e1b 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,10 +1,15 @@ -use crate::bridge::invoke; -use crate::popup::Popup; +use std::time::Duration; + +use feshared::daemon::DaemonState; use leptos::{prelude::*, reactive::spawn_local}; use leptos_router::{ components::{Route, Router, Routes}, path, }; +use wasm_bindgen::JsValue; + +use crate::bridge::invoke; +use crate::popup::Popup; #[component] pub fn App() -> impl IntoView { @@ -26,19 +31,37 @@ fn Dashboard() -> impl IntoView { invoke("toggle_popup", empty_args).await; }); }; - let prompt = |_ev: leptos::ev::MouseEvent| { - spawn_local(async { - let prompt = - serde_wasm_bindgen::to_value(&serde_json::json!({"prompt": "jee juu juu"})) - .unwrap(); - invoke("prompt_llm", prompt).await; - }); - }; view! {

"AI Dashboard"

- - + +
} } + +#[component] +fn DaemonStatusIndicator() -> 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 = 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 + }); + view! { +
+ {move || match status.get() { + Some(state) => match state.is_ok { + true => view! { {"OK"}}, + false => view! { {"DOWN"}}, + }, + None => view! { { "Loading status" } }, + }} +
+ } +}