Add session persistence and browser functionality
Some checks failed
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build aarch64-unknown-linux-musl (push) Has been cancelled
Release / Build armv7-unknown-linux-gnueabihf (push) Has been cancelled
Release / Build armv7-unknown-linux-musleabihf (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-musl (push) Has been cancelled
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build aarch64-pc-windows-msvc (push) Has been cancelled
Release / Build x86_64-pc-windows-msvc (push) Has been cancelled
Release / Create Release (push) Has been cancelled

- Implement `StorageManager` for saving, loading, and managing sessions.
- Introduce platform-specific session directories for persistence.
- Add session browser UI for listing, loading, and deleting saved sessions.
- Enable AI-generated descriptions for session summaries.
- Update configurations to support storage settings and description generation.
- Extend README and tests to document and validate new functionality.
This commit is contained in:
2025-10-02 01:33:49 +02:00
parent 1ad6cb8b3f
commit 6b8774f0aa
12 changed files with 818 additions and 26 deletions

View File

@@ -0,0 +1,308 @@
//! Session persistence and storage management
use crate::types::Conversation;
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Metadata about a saved session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
/// Session file path
pub path: PathBuf,
/// Conversation ID
pub id: uuid::Uuid,
/// Optional session name
pub name: Option<String>,
/// Optional AI-generated description
pub description: Option<String>,
/// Number of messages in the conversation
pub message_count: usize,
/// Model used
pub model: String,
/// When the session was created
pub created_at: SystemTime,
/// When the session was last updated
pub updated_at: SystemTime,
}
/// Storage manager for persisting conversations
pub struct StorageManager {
sessions_dir: PathBuf,
}
impl StorageManager {
/// Create a new storage manager with the default sessions directory
pub fn new() -> Result<Self> {
let sessions_dir = Self::default_sessions_dir()?;
Self::with_directory(sessions_dir)
}
/// Create a storage manager with a custom sessions directory
pub fn with_directory(sessions_dir: PathBuf) -> Result<Self> {
// Ensure the directory exists
if !sessions_dir.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| {
Error::Storage(format!(
"Failed to create sessions directory: {}",
e
))
})?;
}
Ok(Self { sessions_dir })
}
/// Get the default sessions directory
/// - Linux: ~/.local/share/owlen/sessions
/// - Windows: %APPDATA%\owlen\sessions
/// - macOS: ~/Library/Application Support/owlen/sessions
pub fn default_sessions_dir() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("sessions"))
}
/// Save a conversation to disk
pub fn save_conversation(&self, conversation: &Conversation, name: Option<String>) -> Result<PathBuf> {
self.save_conversation_with_description(conversation, name, None)
}
/// Save a conversation to disk with an optional description
pub fn save_conversation_with_description(
&self,
conversation: &Conversation,
name: Option<String>,
description: Option<String>
) -> Result<PathBuf> {
let filename = if let Some(ref session_name) = name {
// Use provided name, sanitized
let sanitized = sanitize_filename(session_name);
format!("{}_{}.json", conversation.id, sanitized)
} else {
// Use conversation ID and timestamp
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}_{}.json", conversation.id, timestamp)
};
let path = self.sessions_dir.join(filename);
// Create a saveable version with the name and description
let mut save_conv = conversation.clone();
if name.is_some() {
save_conv.name = name;
}
if description.is_some() {
save_conv.description = description;
}
let json = serde_json::to_string_pretty(&save_conv).map_err(|e| {
Error::Storage(format!("Failed to serialize conversation: {}", e))
})?;
fs::write(&path, json).map_err(|e| {
Error::Storage(format!("Failed to write session file: {}", e))
})?;
Ok(path)
}
/// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
Error::Storage(format!("Failed to read session file: {}", e))
})?;
let conversation: Conversation = serde_json::from_str(&content).map_err(|e| {
Error::Storage(format!("Failed to parse session file: {}", e))
})?;
Ok(conversation)
}
/// List all saved sessions with metadata
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir).map_err(|e| {
Error::Storage(format!("Failed to read sessions directory: {}", e))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
Error::Storage(format!("Failed to read directory entry: {}", e))
})?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
// Try to load the conversation to extract metadata
match self.load_conversation(&path) {
Ok(conv) => {
sessions.push(SessionMeta {
path: path.clone(),
id: conv.id,
name: conv.name.clone(),
description: conv.description.clone(),
message_count: conv.messages.len(),
model: conv.model.clone(),
created_at: conv.created_at,
updated_at: conv.updated_at,
});
}
Err(_) => {
// Skip files that can't be parsed
continue;
}
}
}
// Sort by updated_at, most recent first
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(sessions)
}
/// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref()).map_err(|e| {
Error::Storage(format!("Failed to delete session file: {}", e))
})
}
/// Get the sessions directory path
pub fn sessions_dir(&self) -> &Path {
&self.sessions_dir
}
}
impl Default for StorageManager {
fn default() -> Self {
Self::new().expect("Failed to create default storage manager")
}
}
/// Sanitize a filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else if c.is_whitespace() {
'_'
} else {
'-'
}
})
.collect::<String>()
.chars()
.take(50) // Limit length
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Message;
use tempfile::TempDir;
#[test]
fn test_platform_specific_default_path() {
let path = StorageManager::default_sessions_dir().unwrap();
// Verify it contains owlen/sessions
assert!(path.to_string_lossy().contains("owlen"));
assert!(path.to_string_lossy().contains("sessions"));
// Platform-specific checks
#[cfg(target_os = "linux")]
{
// Linux should use ~/.local/share/owlen/sessions
assert!(path.to_string_lossy().contains(".local/share"));
}
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(path.to_string_lossy().contains("Library/Application Support"));
}
println!("Default sessions directory: {}", path.display());
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
assert_eq!(sanitize_filename("test/path\\file"), "test-path-file");
assert_eq!(sanitize_filename("file:name?"), "file-name-");
}
#[test]
fn test_save_and_load_conversation() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user("Hello".to_string()));
conv.messages.push(Message::assistant("Hi there!".to_string()));
// Save conversation
let path = storage.save_conversation(&conv, Some("test_session".to_string())).unwrap();
assert!(path.exists());
// Load conversation
let loaded = storage.load_conversation(&path).unwrap();
assert_eq!(loaded.id, conv.id);
assert_eq!(loaded.model, conv.model);
assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.name, Some("test_session".to_string()));
}
#[test]
fn test_list_sessions() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
// Create multiple sessions
for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i)));
storage.save_conversation(&conv, Some(format!("session_{}", i))).unwrap();
}
// List sessions
let sessions = storage.list_sessions().unwrap();
assert_eq!(sessions.len(), 3);
// Check that sessions are sorted by updated_at (most recent first)
for i in 0..sessions.len() - 1 {
assert!(sessions[i].updated_at >= sessions[i + 1].updated_at);
}
}
#[test]
fn test_delete_session() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let conv = Conversation::new("test-model".to_string());
let path = storage.save_conversation(&conv, None).unwrap();
assert!(path.exists());
storage.delete_session(&path).unwrap();
assert!(!path.exists());
}
}