feat: store chat_id to allow multiple chats, use enums in tauri

communication
This commit is contained in:
2026-02-22 11:52:42 +02:00
parent edc425e28f
commit 6364fdf1cd
11 changed files with 129 additions and 59 deletions

View File

@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
is_user BOOL NOT NULL
);

View File

@@ -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);

View File

@@ -1,22 +1,29 @@
use anyhow::Result; use anyhow::Result;
use directories::ProjectDirs; use directories::ProjectDirs;
use sqlx::sqlite::SqliteConnectOptions; use sqlx::sqlite::SqliteConnectOptions;
use sqlx::Row; use sqlx::{Row, SqlitePool};
use sqlx::SqlitePool;
use tokio::fs; use tokio::fs;
use tonic::async_trait; use tonic::async_trait;
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
pub struct ChatMessageData { pub struct ChatMessageData {
pub id: i64, pub id: i64,
pub chat_id: i64,
pub text: String, pub text: String,
pub is_user: bool, pub is_user: bool,
} }
#[async_trait] #[async_trait]
pub trait ChatRepository { pub trait ChatRepository {
async fn save_message(&self, text: &str, is_user: &bool) -> Result<ChatMessageData>; async fn save_message(
async fn get_latest_messages(&self) -> Result<Vec<ChatMessageData>>; &self,
text: &str,
is_user: &bool,
chat_id: &i32,
) -> Result<ChatMessageData>;
async fn get_latest_messages(&self, chat_id: &i32, count: &i32)
-> Result<Vec<ChatMessageData>>;
async fn get_chat_ids(&self) -> Result<Box<[i32]>>;
} }
pub struct SqliteChatRepository { pub struct SqliteChatRepository {
@@ -51,31 +58,46 @@ impl SqliteChatRepository {
#[async_trait] #[async_trait]
impl ChatRepository for SqliteChatRepository { impl ChatRepository for SqliteChatRepository {
async fn save_message(&self, text: &str, is_user: &bool) -> Result<ChatMessageData> { async fn save_message(
&self,
text: &str,
is_user: &bool,
chat_id: &i32,
) -> Result<ChatMessageData> {
let result = sqlx::query_as::<_, ChatMessageData>( let result = sqlx::query_as::<_, ChatMessageData>(
r#" r#"
INSERT INTO messages (text, is_user) INSERT INTO messages (text, is_user, chat_id)
VALUES (?, ?) VALUES (?, ?, ?)
RETURNING id, text, is_user RETURNING id, chat_id, text, is_user
"#, "#,
) )
.bind(text) .bind(text)
.bind(is_user) .bind(is_user)
.bind(chat_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.inspect_err(|e| println!("sql error: {}", e))?; .inspect_err(|e| println!("sql error: {}", e))?;
Ok(result) Ok(result)
} }
async fn get_latest_messages(&self) -> Result<Vec<ChatMessageData>> { async fn get_latest_messages(
&self,
chat_id: &i32,
count: &i32,
) -> Result<Vec<ChatMessageData>> {
// From all chat ids get the latest id.
let rows = sqlx::query( let rows = sqlx::query(
r#" format!(
SELECT * FROM ( r#"
SELECT id, text, is_user SELECT * FROM (
FROM messages SELECT id, chat_id, text, is_user
ORDER BY id DESC FROM messages
LIMIT 10 WHERE chat_id = {chat_id}
) AS subquery ORDER BY id ASC"#, ORDER BY id DESC
LIMIT {count}
) AS subquery ORDER BY id ASC;"#
)
.as_str(),
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
@@ -85,11 +107,27 @@ impl ChatRepository for SqliteChatRepository {
.into_iter() .into_iter()
.map(|row| ChatMessageData { .map(|row| ChatMessageData {
id: row.get(0), id: row.get(0),
text: row.get(1), chat_id: row.get(1),
is_user: row.get(2), text: row.get(2),
is_user: row.get(3),
}) })
.collect(); .collect();
Ok(messages) Ok(messages)
} }
async fn get_chat_ids(&self) -> Result<Box<[i32]>> {
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<i32> = rows
.into_iter()
.map(|row| {
let i: i32 = row.get(0);
i
})
.collect();
Ok(ids.into_boxed_slice())
}
} }

View File

@@ -28,7 +28,10 @@ impl DaemonServer {
impl AiDaemon for DaemonServer { impl AiDaemon for DaemonServer {
async fn chat(&self, request: Request<CRequest>) -> Result<Response<CResponse>, Status> { async fn chat(&self, request: Request<CRequest>) -> Result<Response<CResponse>, Status> {
let r = request.into_inner(); 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 .await
.map_err(|e| Status::new(Code::Internal, e.to_string()))?; .map_err(|e| Status::new(Code::Internal, e.to_string()))?;
messages.push(ChatMessage::user(r.text())); messages.push(ChatMessage::user(r.text()));
@@ -42,7 +45,7 @@ impl AiDaemon for DaemonServer {
let user_message = message_to_dto( let user_message = message_to_dto(
&self &self
.repo .repo
.save_message(r.text(), &true) .save_message(r.text(), &true, &0)
.await .await
.map_err(|e| Status::new(Code::Internal, e.to_string()))?, .map_err(|e| Status::new(Code::Internal, e.to_string()))?,
); );
@@ -52,12 +55,12 @@ impl AiDaemon for DaemonServer {
}; };
println!("User: {}", r.text()); println!("User: {}", r.text());
println!("AI: {}", response_text.clone()); println!("AI: {}", response_text);
let ai_message = message_to_dto( let ai_message = message_to_dto(
&self &self
.repo .repo
.save_message(response_text, &false) .save_message(response_text, &false, &0)
.await .await
.map_err(|e| Status::new(Code::Internal, e.to_string()))?, .map_err(|e| Status::new(Code::Internal, e.to_string()))?,
); );
@@ -70,11 +73,14 @@ impl AiDaemon for DaemonServer {
async fn chat_history( async fn chat_history(
&self, &self,
_: Request<ChatHistoryRequest>, request: Request<ChatHistoryRequest>,
) -> Result<Response<ChatHistoryResponse>, Status> { ) -> Result<Response<ChatHistoryResponse>, 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 let messages = self
.repo .repo
.get_latest_messages() .get_latest_messages(&chat_id, &20)
.await .await
.map_err(|e| Status::new(Code::Internal, e.to_string()))?; .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<dyn ChatRepository + Send + Sync>) -> Result<Vec<ChatMessage>> { async fn gather_history(
let messages = repo.get_latest_messages().await?; repo: Arc<dyn ChatRepository + Send + Sync>,
chat_id: &i32,
) -> Result<Vec<ChatMessage>> {
let messages = repo.get_latest_messages(chat_id, &10).await?;
Ok(messages Ok(messages
.iter() .iter()
.map(|m| match m.is_user { .map(|m| match m.is_user {
@@ -116,3 +125,13 @@ async fn gather_history(repo: Arc<dyn ChatRepository + Send + Sync>) -> Result<V
}) })
.collect()) .collect())
} }
async fn get_chat_id(
repo: Arc<dyn ChatRepository + Send + Sync>,
chat_id: Option<i64>,
) -> Result<i32> {
Ok(match chat_id {
Some(i) => i as i32,
None => repo.get_chat_ids().await?.get(0).copied().unwrap_or(0),
})
}

View File

@@ -3,7 +3,6 @@ mod daemongrpc;
use std::sync::Arc; use std::sync::Arc;
use genai::chat::{ChatMessage, ChatRequest};
use genai::Client; use genai::Client;
use shared::ai::ai_daemon_server::AiDaemonServer; use shared::ai::ai_daemon_server::AiDaemonServer;
use tonic::transport::Server; use tonic::transport::Server;
@@ -11,20 +10,6 @@ use tonic::transport::Server;
use chatpersistence::SqliteChatRepository; use chatpersistence::SqliteChatRepository;
use daemongrpc::DaemonServer; use daemongrpc::DaemonServer;
async fn prompt_ollama(
client: &Client,
model: &str,
prompt: &str,
) -> Result<String, Box<dyn std::error::Error>> {
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] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let chat_repo = SqliteChatRepository::new().await?; let chat_repo = SqliteChatRepository::new().await?;

View File

@@ -13,6 +13,26 @@ pub mod chatmessage {
pub chat_id: Option<i64>, pub chat_id: Option<i64>,
pub history: Vec<Message>, pub history: Vec<Message>,
} }
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 { pub mod daemon {

View File

@@ -72,7 +72,7 @@ pub async fn chat_history(
) -> Result<MessageHistory, String> { ) -> Result<MessageHistory, String> {
let mut client = state.grpc_client.lock().await; let mut client = state.grpc_client.lock().await;
let result = client let result = client
.chat_history(ChatHistoryRequest { chat_id: None }) .chat_history(ChatHistoryRequest { chat_id: chat_id })
.await; .await;
match result { match result {
Ok(response) => { Ok(response) => {

View File

@@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use feshared::daemon::DaemonState; use feshared::{chatmessage::TauriCommand, daemon::DaemonState};
use leptos::{prelude::*, reactive::spawn_local}; use leptos::{prelude::*, reactive::spawn_local};
use leptos_router::{ use leptos_router::{
components::{Route, Router, Routes}, components::{Route, Router, Routes},
@@ -34,7 +34,7 @@ fn Dashboard() -> impl IntoView {
let on_click = move |_ev: leptos::ev::MouseEvent| { let on_click = move |_ev: leptos::ev::MouseEvent| {
spawn_local(async move { spawn_local(async move {
let empty_args = serde_wasm_bindgen::to_value(&serde_json::json!({})).unwrap(); 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! { view! {
@@ -60,7 +60,7 @@ pub fn DaemonStatusIndicator() -> impl IntoView {
); );
let status = LocalResource::new(move || async move { let status = LocalResource::new(move || async move {
poll_count.get(); poll_count.get();
let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; let s: DaemonState = invoke_typed(TauriCommand::DaemonState, JsValue::NULL).await;
s s
}); });

View File

@@ -1,3 +1,4 @@
use feshared::chatmessage::TauriCommand;
use serde::{de::DeserializeOwned, Deserialize}; use serde::{de::DeserializeOwned, Deserialize};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen] #[wasm_bindgen]
@@ -9,11 +10,11 @@ extern "C" {
pub async fn listen(event: &str, handler: &Closure<dyn FnMut(JsValue)>) -> JsValue; pub async fn listen(event: &str, handler: &Closure<dyn FnMut(JsValue)>) -> JsValue;
} }
pub async fn invoke_typed<T>(cmd: &str, args: JsValue) -> T pub async fn invoke_typed<T>(cmd: TauriCommand, args: JsValue) -> T
where where
T: DeserializeOwned, 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(); let result: T = serde_wasm_bindgen::from_value(response).unwrap();
result result
} }

View File

@@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use feshared::daemon::DaemonState; use feshared::{chatmessage::TauriCommand, daemon::DaemonState};
use leptos::{component, prelude::*, reactive::spawn_local, view, IntoView}; use leptos::{component, prelude::*, reactive::spawn_local, view, IntoView};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
@@ -16,7 +16,7 @@ pub fn DaemonProvider(children: ChildrenFn) -> impl IntoView {
); );
let status_res = LocalResource::new(move || async move { let status_res = LocalResource::new(move || async move {
poll_count.get(); poll_count.get();
let s: DaemonState = invoke_typed("daemon_state", JsValue::NULL).await; let s: DaemonState = invoke_typed(TauriCommand::DaemonState, JsValue::NULL).await;
s s
}); });
@@ -88,7 +88,7 @@ pub fn ThemeProvider(children: Children) -> impl IntoView {
pub fn DarkModeToggle() -> impl IntoView { pub fn DarkModeToggle() -> impl IntoView {
let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| {
spawn_local(async { spawn_local(async {
let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; let _ = invoke(TauriCommand::ToggleDarkMode.as_str(), JsValue::UNDEFINED).await;
}); });
}; };
view! { view! {

View File

@@ -3,7 +3,7 @@ use crate::{
components::{DaemonProvider, DarkModeToggle, ThemeProvider}, components::{DaemonProvider, DarkModeToggle, ThemeProvider},
}; };
use feshared::{ use feshared::{
chatmessage::{Message, MessageHistory}, chatmessage::{Message, MessageHistory, TauriCommand},
daemon::DaemonState, daemon::DaemonState,
}; };
use leptos::{ev::keydown, html::Input, prelude::*}; 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 init_history = Action::new_local(|(): &()| async move {
let history: MessageHistory = invoke_typed( let history: MessageHistory = invoke_typed(
"chat_history", TauriCommand::ChatHistory,
serde_wasm_bindgen::to_value(&serde_json::json!({"chat_id": 1})).unwrap(), serde_wasm_bindgen::to_value(&serde_json::json!({"chat_id": 1})).unwrap(),
) )
.await; .await;
@@ -55,7 +55,7 @@ pub fn Popup() -> impl IntoView {
let prompt = prompt.clone(); let prompt = prompt.clone();
async move { async move {
let result: Vec<Message> = invoke_typed( let result: Vec<Message> = invoke_typed(
"chat", TauriCommand::Chat,
serde_wasm_bindgen::to_value(&serde_json::json!({"prompt": prompt})).unwrap(), serde_wasm_bindgen::to_value(&serde_json::json!({"prompt": prompt})).unwrap(),
) )
.await; .await;
@@ -88,7 +88,7 @@ pub fn Popup() -> impl IntoView {
let _ = window_event_listener(keydown, move |ev| { let _ = window_event_listener(keydown, move |ev| {
if ev.key() == "Escape" { if ev.key() == "Escape" {
spawn_local(async move { spawn_local(async move {
let _ = invoke("toggle_popup", JsValue::UNDEFINED).await; let _ = invoke(TauriCommand::TogglePopup.as_str(), JsValue::UNDEFINED).await;
}); });
} }
}); });