diff --git a/desktop/src-tauri/src/classroom_commands/chat.rs b/desktop/src-tauri/src/classroom_commands/chat.rs index e624638..d10aac0 100644 --- a/desktop/src-tauri/src/classroom_commands/chat.rs +++ b/desktop/src-tauri/src/classroom_commands/chat.rs @@ -53,6 +53,7 @@ pub async fn classroom_chat( store: State<'_, ClassroomStore>, chat_store: State<'_, ChatStore>, kernel_state: State<'_, KernelState>, + persistence: State<'_, crate::classroom_commands::persist::ClassroomPersistence>, request: ClassroomChatCmdRequest, ) -> Result, String> { if request.user_message.trim().is_empty() { @@ -102,6 +103,11 @@ pub async fn classroom_chat( state.messages.push(user_msg); state.messages.extend(agent_responses.clone()); + + // Persist chat to SQLite + if let Err(e) = persistence.save_chat(&request.classroom_id, &state.messages).await { + tracing::warn!("[ClassroomChat] Failed to persist chat for {}: {}", request.classroom_id, e); + } } Ok(agent_responses) diff --git a/desktop/src-tauri/src/classroom_commands/generate.rs b/desktop/src-tauri/src/classroom_commands/generate.rs index 2576ef3..38d02dc 100644 --- a/desktop/src-tauri/src/classroom_commands/generate.rs +++ b/desktop/src-tauri/src/classroom_commands/generate.rs @@ -95,6 +95,7 @@ pub async fn classroom_generate( store: State<'_, ClassroomStore>, tasks: State<'_, GenerationTasks>, kernel_state: State<'_, KernelState>, + persistence: State<'_, crate::classroom_commands::persist::ClassroomPersistence>, request: ClassroomGenerateRequest, ) -> Result { if request.topic.trim().is_empty() { @@ -217,7 +218,11 @@ pub async fn classroom_generate( // Store classroom { let mut s = store.lock().await; - s.insert(classroom_id.clone(), classroom); + s.insert(classroom_id.clone(), classroom.clone()); + // Persist to SQLite + if let Err(e) = persistence.save_classroom(&classroom).await { + tracing::warn!("[ClassroomGenerate] Failed to persist classroom {}: {}", classroom_id, e); + } } // Clear generation task diff --git a/desktop/src-tauri/src/classroom_commands/mod.rs b/desktop/src-tauri/src/classroom_commands/mod.rs index 4aa179f..32b5e01 100644 --- a/desktop/src-tauri/src/classroom_commands/mod.rs +++ b/desktop/src-tauri/src/classroom_commands/mod.rs @@ -7,11 +7,13 @@ use std::sync::Arc; use tokio::sync::Mutex; +use tauri::Manager; use zclaw_kernel::generation::Classroom; pub mod chat; pub mod export; pub mod generate; +pub mod persist; // --------------------------------------------------------------------------- // Shared state types @@ -27,6 +29,7 @@ pub type GenerationTasks = Arc ClassroomStore { pub fn create_generation_tasks() -> GenerationTasks { Arc::new(Mutex::new(std::collections::HashMap::new())) } + +/// Create and initialize the classroom persistence layer, loading saved data. +pub async fn init_persistence( + app_handle: &tauri::AppHandle, + store: &ClassroomStore, + chat_store: &ChatStore, +) -> Result { + let app_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + let db_path = app_dir.join("classroom").join("classrooms.db"); + std::fs::create_dir_all(db_path.parent().unwrap()) + .map_err(|e| format!("Failed to create classroom dir: {}", e))?; + + let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?; + let loaded = persistence.load_all(store, chat_store).await?; + tracing::info!("[ClassroomPersistence] Loaded {} classrooms from SQLite", loaded); + Ok(persistence) +} diff --git a/desktop/src-tauri/src/classroom_commands/persist.rs b/desktop/src-tauri/src/classroom_commands/persist.rs new file mode 100644 index 0000000..52cdefb --- /dev/null +++ b/desktop/src-tauri/src/classroom_commands/persist.rs @@ -0,0 +1,171 @@ +//! Classroom SQLite persistence +//! +//! Persists Classroom + ChatState to a local SQLite database so that +//! classroom data survives app restarts. Uses JSON blobs for simplicity +//! since the data is always loaded/written as a whole unit. + +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use sqlx::SqliteConnection; +use sqlx::Connection; +use zclaw_kernel::generation::{Classroom, ClassroomChatMessage, ClassroomChatState}; + +use super::{ClassroomStore, chat::ChatStore}; + +/// SQLite-backed persistence for classrooms and chat history. +pub struct ClassroomPersistence { + conn: Arc>, +} + +impl ClassroomPersistence { + /// Open an in-memory database (no persistence across restarts, used as fallback). + pub async fn open_in_memory() -> Result { + let conn = SqliteConnection::connect("sqlite::memory:") + .await + .map_err(|e| format!("Failed to open in-memory classroom DB: {}", e))?; + let persist = Self { + conn: Arc::new(Mutex::new(conn)), + }; + persist.init_schema().await?; + Ok(persist) + } + + /// Open (or create) the classroom database at the given path. + pub async fn open(db_path: impl Into) -> Result { + let db_path = db_path.into(); + let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + let conn = SqliteConnection::connect(&db_url) + .await + .map_err(|e| format!("Failed to open classroom DB: {}", e))?; + + let persist = Self { + conn: Arc::new(Mutex::new(conn)), + }; + persist.init_schema().await?; + Ok(persist) + } + + async fn init_schema(&self) -> Result<(), String> { + let mut conn = self.conn.lock().await; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS classrooms ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS classroom_chats ( + classroom_id TEXT PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + ) + .execute(&mut *conn) + .await + .map_err(|e| format!("Failed to create classroom schema: {}", e))?; + Ok(()) + } + + /// Save a classroom to SQLite. + pub async fn save_classroom(&self, classroom: &Classroom) -> Result<(), String> { + let mut conn = self.conn.lock().await; + let data = serde_json::to_string(classroom) + .map_err(|e| format!("Failed to serialize classroom: {}", e))?; + sqlx::query( + "INSERT OR REPLACE INTO classrooms (id, data) VALUES (?, ?)" + ) + .bind(&classroom.id) + .bind(&data) + .execute(&mut *conn) + .await + .map_err(|e| format!("Failed to save classroom: {}", e))?; + Ok(()) + } + + /// Save chat state for a classroom. + pub async fn save_chat(&self, classroom_id: &str, messages: &[ClassroomChatMessage]) -> Result<(), String> { + let mut conn = self.conn.lock().await; + let data = serde_json::to_string(messages) + .map_err(|e| format!("Failed to serialize chat messages: {}", e))?; + sqlx::query( + "INSERT OR REPLACE INTO classroom_chats (classroom_id, data) VALUES (?, ?)" + ) + .bind(classroom_id) + .bind(&data) + .execute(&mut *conn) + .await + .map_err(|e| format!("Failed to save chat: {}", e))?; + Ok(()) + } + + /// Delete a classroom and its chat history. + pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> { + let mut conn = self.conn.lock().await; + sqlx::query("DELETE FROM classrooms WHERE id = ?") + .bind(classroom_id) + .execute(&mut *conn) + .await + .map_err(|e| format!("Failed to delete classroom: {}", e))?; + sqlx::query("DELETE FROM classroom_chats WHERE classroom_id = ?") + .bind(classroom_id) + .execute(&mut *conn) + .await + .map_err(|e| format!("Failed to delete chat: {}", e))?; + Ok(()) + } + + /// Load all persisted classrooms into the in-memory store. + pub async fn load_all(&self, store: &ClassroomStore, chat_store: &ChatStore) -> Result { + let mut conn = self.conn.lock().await; + + // Load classrooms + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT id, data FROM classrooms" + ) + .fetch_all(&mut *conn) + .await + .map_err(|e| format!("Failed to load classrooms: {}", e))?; + + let mut loaded = 0; + let mut s = store.lock().await; + for (id, data) in &rows { + match serde_json::from_str::(data) { + Ok(classroom) => { + s.insert(id.clone(), classroom); + loaded += 1; + } + Err(e) => { + tracing::warn!("Failed to deserialize classroom {}: {}", id, e); + } + } + } + drop(s); + + // Load chat history + let chat_rows: Vec<(String, String)> = sqlx::query_as( + "SELECT classroom_id, data FROM classroom_chats" + ) + .fetch_all(&mut *conn) + .await + .map_err(|e| format!("Failed to load chats: {}", e))?; + + let mut cs = chat_store.lock().await; + for (classroom_id, data) in &chat_rows { + match serde_json::from_str::>(data) { + Ok(messages) => { + cs.insert(classroom_id.clone(), ClassroomChatState { + messages, + active: true, + }); + } + Err(e) => { + tracing::warn!("Failed to deserialize chat for {}: {}", classroom_id, e); + } + } + } + + Ok(loaded) + } +}