batuta/serve/banco/
conversations.rs1use crate::serve::templates::ChatMessage;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11
12#[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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Conversation {
26 pub meta: ConversationMeta,
27 pub messages: Vec<ChatMessage>,
28}
29
30pub struct ConversationStore {
32 conversations: RwLock<HashMap<String, Conversation>>,
33 data_dir: Option<PathBuf>,
34 counter: std::sync::atomic::AtomicU64,
35}
36
37impl ConversationStore {
38 #[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 #[must_use]
50 pub fn with_data_dir(dir: PathBuf) -> Arc<Self> {
51 let _ = std::fs::create_dir_all(&dir);
52
53 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 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 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 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 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 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 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 #[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 #[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 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 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 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 #[must_use]
206 pub fn len(&self) -> usize {
207 self.conversations.read().map(|s| s.len()).unwrap_or(0)
208 }
209
210 #[must_use]
212 pub fn is_empty(&self) -> bool {
213 self.len() == 0
214 }
215
216 #[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 #[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 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
254fn 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#[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 {}