Skip to main content

batuta/serve/banco/
conversations.rs

1//! Conversation persistence for Banco.
2//!
3//! Stores conversations as JSONL files in `~/.banco/conversations/`.
4//! Each conversation has an ID, title, and append-only message log.
5
6use crate::serve::templates::ChatMessage;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11
12/// Metadata for a single conversation (stored in index).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ConversationMeta {
15    pub id: String,
16    pub title: String,
17    pub model: String,
18    pub created: u64,
19    pub updated: u64,
20    pub message_count: usize,
21}
22
23/// A full conversation with messages.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Conversation {
26    pub meta: ConversationMeta,
27    pub messages: Vec<ChatMessage>,
28}
29
30/// In-memory conversation store with optional disk persistence.
31pub struct ConversationStore {
32    conversations: RwLock<HashMap<String, Conversation>>,
33    data_dir: Option<PathBuf>,
34    counter: std::sync::atomic::AtomicU64,
35}
36
37impl ConversationStore {
38    /// Create an in-memory-only store (for testing).
39    #[must_use]
40    pub fn in_memory() -> Arc<Self> {
41        Arc::new(Self {
42            conversations: RwLock::new(HashMap::new()),
43            data_dir: None,
44            counter: std::sync::atomic::AtomicU64::new(0),
45        })
46    }
47
48    /// Create a store backed by `~/.banco/conversations/`.
49    #[must_use]
50    pub fn with_data_dir(dir: PathBuf) -> Arc<Self> {
51        let _ = std::fs::create_dir_all(&dir);
52
53        // Load existing conversations from JSONL files
54        let mut conversations = HashMap::new();
55        let mut max_seq = 0u64;
56        if let Ok(entries) = std::fs::read_dir(&dir) {
57            for entry in entries.flatten() {
58                let path = entry.path();
59                if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
60                    let conv_id =
61                        path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
62
63                    // Read messages from JSONL
64                    let mut messages = Vec::new();
65                    if let Ok(content) = std::fs::read_to_string(&path) {
66                        for line in content.lines() {
67                            if let Ok(msg) = serde_json::from_str::<ChatMessage>(line) {
68                                messages.push(msg);
69                            }
70                        }
71                    }
72
73                    // Extract sequence from ID
74                    if let Some(seq_str) = conv_id.rsplit('-').next() {
75                        if let Ok(seq) = seq_str.parse::<u64>() {
76                            max_seq = max_seq.max(seq + 1);
77                        }
78                    }
79
80                    let title = messages
81                        .first()
82                        .filter(|m| matches!(m.role, crate::serve::templates::Role::User))
83                        .map(|m| auto_title(&m.content))
84                        .unwrap_or_else(|| "Loaded conversation".to_string());
85
86                    let conv = Conversation {
87                        meta: ConversationMeta {
88                            id: conv_id.clone(),
89                            title,
90                            model: "unknown".to_string(),
91                            created: epoch_secs(),
92                            updated: epoch_secs(),
93                            message_count: messages.len(),
94                        },
95                        messages,
96                    };
97                    conversations.insert(conv_id, conv);
98                }
99            }
100        }
101
102        let loaded = conversations.len();
103        if loaded > 0 {
104            eprintln!("[banco] Loaded {loaded} conversations from {}", dir.display());
105        }
106
107        Arc::new(Self {
108            conversations: RwLock::new(conversations),
109            data_dir: Some(dir),
110            counter: std::sync::atomic::AtomicU64::new(max_seq),
111        })
112    }
113
114    /// Create a new conversation, returning its ID.
115    pub fn create(&self, model: &str) -> String {
116        let seq = self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
117        let id = format!("conv-{}-{seq}", epoch_secs());
118        let meta = ConversationMeta {
119            id: id.clone(),
120            title: "New conversation".to_string(),
121            model: model.to_string(),
122            created: epoch_secs(),
123            updated: epoch_secs(),
124            message_count: 0,
125        };
126        let conv = Conversation { meta, messages: Vec::new() };
127        if let Ok(mut store) = self.conversations.write() {
128            store.insert(id.clone(), conv);
129        }
130        id
131    }
132
133    /// Append a message to a conversation. Auto-titles on first user message.
134    pub fn append(&self, id: &str, message: ChatMessage) -> Result<(), ConversationError> {
135        let mut store = self.conversations.write().map_err(|_| ConversationError::LockPoisoned)?;
136        let conv = store.get_mut(id).ok_or(ConversationError::NotFound(id.to_string()))?;
137
138        // Auto-title from first user message
139        if conv.messages.is_empty()
140            && conv.meta.title == "New conversation"
141            && matches!(message.role, crate::serve::templates::Role::User)
142        {
143            conv.meta.title = auto_title(&message.content);
144        }
145
146        conv.messages.push(message);
147        conv.meta.message_count = conv.messages.len();
148        conv.meta.updated = epoch_secs();
149
150        // Persist to disk if configured
151        if let Some(ref dir) = self.data_dir {
152            let path = dir.join(format!("{id}.jsonl"));
153            let json = serde_json::to_string(&conv.messages.last().expect("just pushed"))
154                .unwrap_or_default();
155            let _ = std::fs::OpenOptions::new().create(true).append(true).open(path).and_then(
156                |mut f| {
157                    use std::io::Write;
158                    writeln!(f, "{json}")
159                },
160            );
161        }
162
163        Ok(())
164    }
165
166    /// List all conversations (most recent first).
167    #[must_use]
168    pub fn list(&self) -> Vec<ConversationMeta> {
169        let store = self.conversations.read().unwrap_or_else(|e| e.into_inner());
170        let mut metas: Vec<ConversationMeta> = store.values().map(|c| c.meta.clone()).collect();
171        metas.sort_by(|a, b| b.updated.cmp(&a.updated));
172        metas
173    }
174
175    /// Get a conversation by ID.
176    #[must_use]
177    pub fn get(&self, id: &str) -> Option<Conversation> {
178        let store = self.conversations.read().unwrap_or_else(|e| e.into_inner());
179        store.get(id).cloned()
180    }
181
182    /// Rename a conversation.
183    pub fn rename(&self, id: &str, title: &str) -> Result<(), ConversationError> {
184        let mut store = self.conversations.write().map_err(|_| ConversationError::LockPoisoned)?;
185        let conv = store.get_mut(id).ok_or(ConversationError::NotFound(id.to_string()))?;
186        conv.meta.title = title.to_string();
187        conv.meta.updated = epoch_secs();
188        Ok(())
189    }
190
191    /// Delete a conversation by ID.
192    pub fn delete(&self, id: &str) -> Result<(), ConversationError> {
193        let mut store = self.conversations.write().map_err(|_| ConversationError::LockPoisoned)?;
194        store.remove(id).ok_or(ConversationError::NotFound(id.to_string()))?;
195
196        // Remove from disk
197        if let Some(ref dir) = self.data_dir {
198            let _ = std::fs::remove_file(dir.join(format!("{id}.jsonl")));
199        }
200
201        Ok(())
202    }
203
204    /// Number of conversations.
205    #[must_use]
206    pub fn len(&self) -> usize {
207        self.conversations.read().map(|s| s.len()).unwrap_or(0)
208    }
209
210    /// Check if empty.
211    #[must_use]
212    pub fn is_empty(&self) -> bool {
213        self.len() == 0
214    }
215
216    /// Search conversations by content (case-insensitive substring match).
217    #[must_use]
218    pub fn search(&self, query: &str) -> Vec<ConversationMeta> {
219        let store = self.conversations.read().unwrap_or_else(|e| e.into_inner());
220        let query_lower = query.to_lowercase();
221        let mut results: Vec<ConversationMeta> = store
222            .values()
223            .filter(|c| {
224                c.meta.title.to_lowercase().contains(&query_lower)
225                    || c.messages.iter().any(|m| m.content.to_lowercase().contains(&query_lower))
226            })
227            .map(|c| c.meta.clone())
228            .collect();
229        results.sort_by(|a, b| b.updated.cmp(&a.updated));
230        results
231    }
232
233    /// Export all conversations as a JSON-serializable vec.
234    #[must_use]
235    pub fn export_all(&self) -> Vec<Conversation> {
236        let store = self.conversations.read().unwrap_or_else(|e| e.into_inner());
237        let mut convs: Vec<Conversation> = store.values().cloned().collect();
238        convs.sort_by(|a, b| b.meta.updated.cmp(&a.meta.updated));
239        convs
240    }
241
242    /// Import conversations, merging by ID (existing conversations are overwritten).
243    /// Returns the number of conversations imported.
244    pub fn import_all(&self, conversations: Vec<Conversation>) -> usize {
245        let mut store = self.conversations.write().unwrap_or_else(|e| e.into_inner());
246        let count = conversations.len();
247        for conv in conversations {
248            store.insert(conv.meta.id.clone(), conv);
249        }
250        count
251    }
252}
253
254/// Auto-generate a title from the first user message (first 5 words).
255fn auto_title(content: &str) -> String {
256    let words: Vec<&str> = content.split_whitespace().take(5).collect();
257    if words.is_empty() {
258        "New conversation".to_string()
259    } else {
260        let mut title = words.join(" ");
261        if content.split_whitespace().count() > 5 {
262            title.push_str("...");
263        }
264        title
265    }
266}
267
268fn epoch_secs() -> u64 {
269    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()
270}
271
272/// Conversation errors.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum ConversationError {
275    NotFound(String),
276    LockPoisoned,
277}
278
279impl std::fmt::Display for ConversationError {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            Self::NotFound(id) => write!(f, "Conversation not found: {id}"),
283            Self::LockPoisoned => write!(f, "Internal lock error"),
284        }
285    }
286}
287
288impl std::error::Error for ConversationError {}