use anyhow::{bail, Context, Result}; use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StoredMessage { pub role: String, pub content: Value, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ThreadRecord { pub id: String, pub title: String, pub model: Option, pub created_at: String, pub updated_at: String, pub messages: Vec, } #[derive(Debug, Clone, Serialize)] pub struct ThreadSummary { pub id: String, pub title: String, pub model: Option, pub created_at: String, pub updated_at: String, pub message_count: usize, pub preview: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PackedDocument { pub name: String, pub content: String, } pub fn ensure_workspace(root: &Path) -> Result<()> { for relative in [ "workspace/documents", "workspace/settings", "workspace/threads", "workspace/exports", "workspace/voice", "workspace/scout", "logs", ] { std::fs::create_dir_all(root.join(relative)) .with_context(|| format!("cannot create {relative}"))?; } Ok(()) } pub fn create_thread( root: &Path, title: Option<&str>, model: Option, ) -> Result { let now = Utc::now().to_rfc3339(); let thread = ThreadRecord { id: Uuid::new_v4().to_string(), title: clean_title(title.unwrap_or("New conversation")), model, created_at: now.clone(), updated_at: now, messages: Vec::new(), }; save_thread(root, &thread)?; Ok(thread) } pub fn list_threads(root: &Path, filter: Option<&str>) -> Result> { let filter = filter.unwrap_or("").trim().to_lowercase(); let mut summaries = Vec::new(); let dir = root.join("workspace/threads"); if !dir.exists() { return Ok(summaries); } for entry in std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))? { let entry = match entry { Ok(value) => value, Err(_) => continue, }; if !entry.path().is_file() || entry.path().extension().and_then(|v| v.to_str()) != Some("json") { continue; } let thread: ThreadRecord = match read_json_typed(&entry.path()) { Ok(value) => value, Err(_) => continue, }; let preview = thread .messages .iter() .rev() .find_map(|message| message.content.as_str()) .unwrap_or("") .chars() .take(160) .collect::(); if !filter.is_empty() && !thread.title.to_lowercase().contains(&filter) && !preview.to_lowercase().contains(&filter) { continue; } summaries.push(ThreadSummary { id: thread.id, title: thread.title, model: thread.model, created_at: thread.created_at, updated_at: thread.updated_at, message_count: thread.messages.len(), preview, }); } summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); Ok(summaries) } pub fn load_thread(root: &Path, id: &str) -> Result { validate_thread_id(id)?; let path = thread_path(root, id); read_json_typed(&path).with_context(|| format!("thread not found: {id}")) } pub fn save_thread(root: &Path, thread: &ThreadRecord) -> Result<()> { validate_thread_id(&thread.id)?; write_json_pretty(&thread_path(root, &thread.id), thread) } pub fn rename_thread(root: &Path, id: &str, title: &str) -> Result { let mut thread = load_thread(root, id)?; thread.title = clean_title(title); thread.updated_at = Utc::now().to_rfc3339(); save_thread(root, &thread)?; Ok(thread) } pub fn delete_thread(root: &Path, id: &str) -> Result<()> { validate_thread_id(id)?; let path = thread_path(root, id); if path.exists() { std::fs::remove_file(&path).with_context(|| format!("cannot delete {}", path.display()))?; } Ok(()) } pub fn append_message(thread: &mut ThreadRecord, role: &str, content: Value) { thread.messages.push(StoredMessage { role: role.to_string(), content, }); thread.updated_at = Utc::now().to_rfc3339(); } pub fn list_documents_with_content(root: &Path) -> Result> { let mut documents = Vec::new(); let dir = root.join("workspace/documents"); if !dir.exists() { return Ok(documents); } for entry in std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))? { let entry = match entry { Ok(value) => value, Err(_) => continue, }; if !entry.path().is_file() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if !safe_filename(&name) { continue; } let content = match std::fs::read_to_string(entry.path()) { Ok(value) => value, Err(_) => continue, }; documents.push(PackedDocument { name, content }); } documents.sort_by(|a, b| a.name.cmp(&b.name)); Ok(documents) } pub fn save_document(root: &Path, name: &str, content: &str) -> Result { if !safe_filename(name) { bail!("unsafe filename"); } let path = root.join("workspace/documents").join(name); std::fs::write(&path, content.as_bytes()) .with_context(|| format!("cannot write {}", path.display()))?; Ok(path) } pub fn read_settings(root: &Path) -> Value { let path = root.join("workspace/settings/settings.json"); read_json_value(&path).unwrap_or_else(|_| { json!({ "mode": "simple", "offline_lock": true, "tools_lock": true, "phone_access_enabled": false }) }) } pub fn write_settings(root: &Path, settings: &Value) -> Result<()> { write_json_pretty(&root.join("workspace/settings/settings.json"), settings) } pub fn read_all_threads(root: &Path) -> Result> { let mut threads = Vec::new(); for summary in list_threads(root, None)? { if let Ok(thread) = load_thread(root, &summary.id) { threads.push(thread); } } Ok(threads) } pub fn import_threads(root: &Path, threads: &[ThreadRecord], replace: bool) -> Result { let dir = root.join("workspace/threads"); if replace && dir.exists() { for entry in std::fs::read_dir(&dir)? { let entry = entry?; if entry.path().is_file() && entry.path().extension().and_then(|v| v.to_str()) == Some("json") { std::fs::remove_file(entry.path())?; } } } let mut count = 0; for thread in threads { validate_thread_id(&thread.id)?; save_thread(root, thread)?; count += 1; } Ok(count) } fn thread_path(root: &Path, id: &str) -> PathBuf { root.join("workspace/threads").join(format!("{id}.json")) } fn validate_thread_id(id: &str) -> Result<()> { Uuid::parse_str(id).with_context(|| "invalid thread id")?; Ok(()) } fn clean_title(title: &str) -> String { let cleaned = title .trim() .chars() .filter(|c| !c.is_control()) .take(120) .collect::(); if cleaned.is_empty() { "New conversation".to_string() } else { cleaned } } fn safe_filename(name: &str) -> bool { !name.is_empty() && name.len() <= 120 && !name.contains("..") && !name.contains('/') && !name.contains('\\') && name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) } fn read_json_value(path: &Path) -> Result { let text = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&text)?) } fn read_json_typed Deserialize<'de>>(path: &Path) -> Result { let text = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&text)?) } fn write_json_pretty(path: &Path, value: &T) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, serde_json::to_vec_pretty(value)?)?; Ok(()) }