jackailocal / src /storage.rs
jackboy70's picture
Deploy: accurate lite-builder note
f25362a
Raw
History Blame Contribute Delete
8.66 kB
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(())
}