| 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<String>, |
| pub created_at: String, |
| pub updated_at: String, |
| pub messages: Vec<StoredMessage>, |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub struct ThreadSummary { |
| pub id: String, |
| pub title: String, |
| pub model: Option<String>, |
| 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<String>, |
| ) -> Result<ThreadRecord> { |
| 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<Vec<ThreadSummary>> { |
| 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::<String>(); |
| 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<ThreadRecord> { |
| 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<ThreadRecord> { |
| 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<Vec<PackedDocument>> { |
| 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<PathBuf> { |
| 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<Vec<ThreadRecord>> { |
| 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<usize> { |
| 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::<String>(); |
| 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<Value> { |
| let text = std::fs::read_to_string(path)?; |
| Ok(serde_json::from_str(&text)?) |
| } |
|
|
| fn read_json_typed<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> { |
| let text = std::fs::read_to_string(path)?; |
| Ok(serde_json::from_str(&text)?) |
| } |
|
|
| fn write_json_pretty<T: Serialize>(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(()) |
| } |
|
|