diff --git a/crates/daemon/migrations/20260214_1_init.sql b/crates/daemon/migrations/20260214_1_init.sql deleted file mode 100644 index 8001e1d..0000000 --- a/crates/daemon/migrations/20260214_1_init.sql +++ /dev/null @@ -1,5 +0,0 @@ -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/migrations/20260222_1_init.sql b/crates/daemon/migrations/20260222_1_init.sql new file mode 100644 index 0000000..6b19c8f --- /dev/null +++ b/crates/daemon/migrations/20260222_1_init.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + text TEXT NOT NULL, + is_user BOOL NOT NULL, + + UNIQUE(id, chat_id) +); + +CREATE INDEX idx_message_timestamp ON messages(timestamp); +CREATE INDEX idx_message_chat_id ON messages(chat_id); diff --git a/crates/daemon/src/chatpersistence.rs b/crates/daemon/src/chatpersistence.rs index 97ecf3d..88ece73 100644 --- a/crates/daemon/src/chatpersistence.rs +++ b/crates/daemon/src/chatpersistence.rs @@ -1,22 +1,29 @@ use anyhow::Result; use directories::ProjectDirs; use sqlx::sqlite::SqliteConnectOptions; -use sqlx::Row; -use sqlx::SqlitePool; +use sqlx::{Row, SqlitePool}; use tokio::fs; use tonic::async_trait; #[derive(Debug, sqlx::FromRow)] pub struct ChatMessageData { pub id: i64, + pub chat_id: i64, pub text: String, pub is_user: bool, } #[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, + chat_id: &i32, + ) -> Result; + async fn get_latest_messages(&self, chat_id: &i32, count: &i32) + -> Result>; + async fn get_chat_ids(&self) -> Result>; } pub struct SqliteChatRepository { @@ -51,31 +58,46 @@ impl SqliteChatRepository { #[async_trait] impl ChatRepository for SqliteChatRepository { - async fn save_message(&self, text: &str, is_user: &bool) -> Result { + async fn save_message( + &self, + text: &str, + is_user: &bool, + chat_id: &i32, + ) -> Result { let result = sqlx::query_as::<_, ChatMessageData>( r#" - INSERT INTO messages (text, is_user) - VALUES (?, ?) - RETURNING id, text, is_user + INSERT INTO messages (text, is_user, chat_id) + VALUES (?, ?, ?) + RETURNING id, chat_id, text, is_user "#, ) .bind(text) .bind(is_user) + .bind(chat_id) .fetch_one(&self.pool) .await .inspect_err(|e| println!("sql error: {}", e))?; Ok(result) } - async fn get_latest_messages(&self) -> Result> { + async fn get_latest_messages( + &self, + chat_id: &i32, + count: &i32, + ) -> Result> { + // From all chat ids get the latest id. let rows = sqlx::query( - r#" - SELECT * FROM ( - SELECT id, text, is_user - FROM messages - ORDER BY id DESC - LIMIT 10 - ) AS subquery ORDER BY id ASC"#, + format!( + r#" + SELECT * FROM ( + SELECT id, chat_id, text, is_user + FROM messages + WHERE chat_id = {chat_id} + ORDER BY id DESC + LIMIT {count} + ) AS subquery ORDER BY id ASC;"# + ) + .as_str(), ) .fetch_all(&self.pool) .await @@ -85,11 +107,27 @@ impl ChatRepository for SqliteChatRepository { .into_iter() .map(|row| ChatMessageData { id: row.get(0), - text: row.get(1), - is_user: row.get(2), + chat_id: row.get(1), + text: row.get(2), + is_user: row.get(3), }) .collect(); Ok(messages) } + + async fn get_chat_ids(&self) -> Result> { + let rows = sqlx::query("SELECT DISTINCT(chat_id) FROM messages ORDER BY chat_id DESC") + .fetch_all(&self.pool) + .await + .inspect_err(|e| println!("sql error: {}", e))?; + let ids: Vec = rows + .into_iter() + .map(|row| { + let i: i32 = row.get(0); + i + }) + .collect(); + Ok(ids.into_boxed_slice()) + } } diff --git a/crates/daemon/src/daemongrpc.rs b/crates/daemon/src/daemongrpc.rs index fb92264..f8f358b 100644 --- a/crates/daemon/src/daemongrpc.rs +++ b/crates/daemon/src/daemongrpc.rs @@ -28,7 +28,10 @@ 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()) + let chat_id = get_chat_id(self.repo.clone(), r.chat_id) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?; + let mut messages = gather_history(self.repo.clone(), &chat_id) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?; messages.push(ChatMessage::user(r.text())); @@ -42,7 +45,7 @@ impl AiDaemon for DaemonServer { let user_message = message_to_dto( &self .repo - .save_message(r.text(), &true) + .save_message(r.text(), &true, &0) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?, ); @@ -52,12 +55,12 @@ impl AiDaemon for DaemonServer { }; println!("User: {}", r.text()); - println!("AI: {}", response_text.clone()); + println!("AI: {}", response_text); let ai_message = message_to_dto( &self .repo - .save_message(response_text, &false) + .save_message(response_text, &false, &0) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?, ); @@ -70,11 +73,14 @@ impl AiDaemon for DaemonServer { async fn chat_history( &self, - _: Request, + request: Request, ) -> Result, Status> { + let chat_id = get_chat_id(self.repo.clone(), request.into_inner().chat_id) + .await + .map_err(|e| Status::new(Code::Internal, e.to_string()))?; let messages = self .repo - .get_latest_messages() + .get_latest_messages(&chat_id, &20) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))?; @@ -106,8 +112,11 @@ pub fn message_to_dto(msg: &ChatMessageData) -> CMessage { } } -async fn gather_history(repo: Arc) -> Result> { - let messages = repo.get_latest_messages().await?; +async fn gather_history( + repo: Arc, + chat_id: &i32, +) -> Result> { + let messages = repo.get_latest_messages(chat_id, &10).await?; Ok(messages .iter() .map(|m| match m.is_user { @@ -116,3 +125,13 @@ async fn gather_history(repo: Arc) -> Result, + chat_id: Option, +) -> Result { + Ok(match chat_id { + Some(i) => i as i32, + None => repo.get_chat_ids().await?.get(0).copied().unwrap_or(0), + }) +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 8a643f7..39fe4c9 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -3,7 +3,6 @@ mod daemongrpc; use std::sync::Arc; -use genai::chat::{ChatMessage, ChatRequest}; use genai::Client; use shared::ai::ai_daemon_server::AiDaemonServer; use tonic::transport::Server; @@ -11,20 +10,6 @@ use tonic::transport::Server; use chatpersistence::SqliteChatRepository; use daemongrpc::DaemonServer; -async fn prompt_ollama( - client: &Client, - model: &str, - prompt: &str, -) -> Result> { - let chat_req = ChatRequest::new(vec![ChatMessage::user(prompt)]); - let chat_res = client.exec_chat(model, chat_req, None).await?; - let output = chat_res - .first_text() - .unwrap_or("No response content!") - .to_string(); - Ok(output) -} - #[tokio::main] async fn main() -> Result<(), Box> { let chat_repo = SqliteChatRepository::new().await?; diff --git a/crates/feshared/src/lib.rs b/crates/feshared/src/lib.rs index 0524d1d..060ac01 100644 --- a/crates/feshared/src/lib.rs +++ b/crates/feshared/src/lib.rs @@ -13,6 +13,26 @@ pub mod chatmessage { pub chat_id: Option, pub history: Vec, } + + pub enum TauriCommand { + Chat, + ChatHistory, + DaemonState, + ToggleDarkMode, + TogglePopup, + } + + impl TauriCommand { + pub fn as_str(&self) -> &'static str { + match self { + TauriCommand::TogglePopup => "toggle_popup", + TauriCommand::Chat => "chat", + TauriCommand::ChatHistory => "chat_history", + TauriCommand::DaemonState => "daemon_state", + TauriCommand::ToggleDarkMode => "toggle_dark_mode", + } + } + } } pub mod daemon { diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index a1f031a..14f8712 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -72,7 +72,7 @@ pub async fn chat_history( ) -> Result { let mut client = state.grpc_client.lock().await; let result = client - .chat_history(ChatHistoryRequest { chat_id: None }) + .chat_history(ChatHistoryRequest { chat_id: chat_id }) .await; match result { Ok(response) => { diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 8af852d..2384b08 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use feshared::daemon::DaemonState; +use feshared::{chatmessage::TauriCommand, daemon::DaemonState}; use leptos::{prelude::*, reactive::spawn_local}; use leptos_router::{ components::{Route, Router, Routes}, @@ -34,7 +34,7 @@ fn Dashboard() -> impl IntoView { let on_click = move |_ev: leptos::ev::MouseEvent| { spawn_local(async move { let empty_args = serde_wasm_bindgen::to_value(&serde_json::json!({})).unwrap(); - invoke("toggle_popup", empty_args).await; + invoke(TauriCommand::TogglePopup.as_str(), empty_args).await; }); }; view! { @@ -60,7 +60,7 @@ pub fn DaemonStatusIndicator() -> impl IntoView { ); let status = LocalResource::new(move || async move { poll_count.get(); - let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; + let s: DaemonState = invoke_typed(TauriCommand::DaemonState, JsValue::NULL).await; s }); diff --git a/frontend/src/bridge.rs b/frontend/src/bridge.rs index 0b065f2..5f901d2 100644 --- a/frontend/src/bridge.rs +++ b/frontend/src/bridge.rs @@ -1,3 +1,4 @@ +use feshared::chatmessage::TauriCommand; use serde::{de::DeserializeOwned, Deserialize}; use wasm_bindgen::prelude::*; #[wasm_bindgen] @@ -9,11 +10,11 @@ extern "C" { pub async fn listen(event: &str, handler: &Closure) -> JsValue; } -pub async fn invoke_typed(cmd: &str, args: JsValue) -> T +pub async fn invoke_typed(cmd: TauriCommand, args: JsValue) -> T where T: DeserializeOwned, { - let response = invoke(cmd, args).await; + let response = invoke(cmd.as_str(), args).await; let result: T = serde_wasm_bindgen::from_value(response).unwrap(); result } diff --git a/frontend/src/components.rs b/frontend/src/components.rs index 5eef11e..16a0d4c 100644 --- a/frontend/src/components.rs +++ b/frontend/src/components.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use feshared::daemon::DaemonState; +use feshared::{chatmessage::TauriCommand, daemon::DaemonState}; use leptos::{component, prelude::*, reactive::spawn_local, view, IntoView}; use serde::{Deserialize, Serialize}; use wasm_bindgen::JsValue; @@ -16,7 +16,7 @@ pub fn DaemonProvider(children: ChildrenFn) -> impl IntoView { ); let status_res = LocalResource::new(move || async move { poll_count.get(); - let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; + let s: DaemonState = invoke_typed(TauriCommand::DaemonState, JsValue::NULL).await; s }); @@ -88,7 +88,7 @@ pub fn ThemeProvider(children: Children) -> impl IntoView { pub fn DarkModeToggle() -> impl IntoView { let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { spawn_local(async { - let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; + let _ = invoke(TauriCommand::ToggleDarkMode.as_str(), JsValue::UNDEFINED).await; }); }; view! { diff --git a/frontend/src/popup.rs b/frontend/src/popup.rs index 81fc671..12efd20 100644 --- a/frontend/src/popup.rs +++ b/frontend/src/popup.rs @@ -3,7 +3,7 @@ use crate::{ components::{DaemonProvider, DarkModeToggle, ThemeProvider}, }; use feshared::{ - chatmessage::{Message, MessageHistory}, + chatmessage::{Message, MessageHistory, TauriCommand}, daemon::DaemonState, }; use leptos::{ev::keydown, html::Input, prelude::*}; @@ -31,7 +31,7 @@ pub fn Popup() -> impl IntoView { let init_history = Action::new_local(|(): &()| async move { let history: MessageHistory = invoke_typed( - "chat_history", + TauriCommand::ChatHistory, serde_wasm_bindgen::to_value(&serde_json::json!({"chat_id": 1})).unwrap(), ) .await; @@ -55,7 +55,7 @@ pub fn Popup() -> impl IntoView { let prompt = prompt.clone(); async move { let result: Vec = invoke_typed( - "chat", + TauriCommand::Chat, serde_wasm_bindgen::to_value(&serde_json::json!({"prompt": prompt})).unwrap(), ) .await; @@ -88,7 +88,7 @@ pub fn Popup() -> impl IntoView { let _ = window_event_listener(keydown, move |ev| { if ev.key() == "Escape" { spawn_local(async move { - let _ = invoke("toggle_popup", JsValue::UNDEFINED).await; + let _ = invoke(TauriCommand::TogglePopup.as_str(), JsValue::UNDEFINED).await; }); } });