Skip to main content

offline_intelligence/api/
conversation_api.rs

1//! API endpoints for conversation/session management
2
3use axum::{
4    extract::{State, Path},
5    response::{IntoResponse, Response},
6    Json,
7};
8use axum::http::StatusCode;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use tracing::{info, error};
12
13use crate::shared_state::UnifiedAppState;
14
15/// Response for fetching all conversations
16#[derive(Debug, Serialize)]
17pub struct ConversationsResponse {
18    pub conversations: Vec<ConversationSummary>,
19}
20
21/// Summary of a conversation for the sidebar
22#[derive(Debug, Serialize)]
23pub struct ConversationSummary {
24    pub id: String,
25    pub title: String,
26    pub created_at: String,
27    pub last_accessed: String,
28    pub message_count: usize,
29    pub pinned: bool,
30}
31
32/// Response for fetching a specific conversation's messages
33#[derive(Debug, Serialize)]
34pub struct ConversationDetailResponse {
35    pub id: String,
36    pub title: String,
37    pub messages: Vec<MessageResponse>,
38}
39
40/// Message format for API response
41#[derive(Debug, Serialize)]
42pub struct MessageResponse {
43    pub role: String,
44    pub content: String,
45}
46
47/// Fetch all conversations/sessions from the database
48pub async fn get_conversations(
49    State(state): State<UnifiedAppState>,
50) -> Result<Json<ConversationsResponse>, Response> {
51    info!("Fetching all conversations");
52    
53    let orchestrator_lock = state.context_orchestrator.read().await;
54    
55    if let Some(ref orchestrator) = *orchestrator_lock {
56        match orchestrator.database().conversations.get_all_sessions() {
57            Ok(sessions) => {
58                let mut conversations = Vec::new();
59                
60                for session in sessions {
61                    // Get message count for this session
62                    let message_count = orchestrator.database().conversations
63                        .get_session_message_count(&session.id)
64                        .unwrap_or(0);
65                    
66                    // Include sessions that have a title OR have messages (even if title not generated yet)
67                    if let Some(ref title) = session.metadata.title {
68                        conversations.push(ConversationSummary {
69                            id: session.id.clone(),
70                            title: title.clone(),
71                            created_at: session.created_at.to_rfc3339(),
72                            last_accessed: session.last_accessed.to_rfc3339(),
73                            message_count,
74                            pinned: session.metadata.pinned,
75                        });
76                    } else if message_count > 0 {
77                        // Get first user message for title
78                        let first_message = orchestrator.database().conversations
79                            .get_session_messages(&session.id, Some(1), Some(0))
80                            .ok()
81                            .and_then(|msgs| msgs.into_iter().find(|m| m.role == "user"));
82                        
83                        let title = first_message
84                            .map(|m| {
85                                let content = m.content.chars().take(50).collect::<String>();
86                                if m.content.len() > 50 {
87                                    format!("{}...", content)
88                                } else {
89                                    content
90                                }
91                            })
92                            .unwrap_or_else(|| format!("Chat {}", session.created_at.format("%b %d, %Y")));
93                        
94                        conversations.push(ConversationSummary {
95                            id: session.id.clone(),
96                            title,
97                            created_at: session.created_at.to_rfc3339(),
98                            last_accessed: session.last_accessed.to_rfc3339(),
99                            message_count,
100                            pinned: session.metadata.pinned,
101                        });
102                    }
103                }
104                
105                info!("Found {} conversations", conversations.len());
106                Ok(Json(ConversationsResponse { conversations }))
107            }
108            Err(e) => {
109                error!("Failed to fetch conversations: {}", e);
110                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
111            }
112        }
113    } else {
114        error!("Context orchestrator not initialized");
115        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
116    }
117}
118
119/// Fetch a specific conversation's messages
120pub async fn get_conversation(
121    State(state): State<UnifiedAppState>,
122    Path(session_id): Path<String>,
123) -> Result<Json<ConversationDetailResponse>, Response> {
124    info!("Fetching conversation: {}", session_id);
125    
126    let orchestrator_lock = state.context_orchestrator.read().await;
127    
128    if let Some(ref orchestrator) = *orchestrator_lock {
129        // Get session metadata
130        let session = match orchestrator.database().conversations.get_session(&session_id) {
131            Ok(Some(s)) => s,
132            Ok(None) => {
133                return Err((StatusCode::NOT_FOUND, "Conversation not found").into_response());
134            }
135            Err(e) => {
136                error!("Failed to fetch session: {}", e);
137                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response());
138            }
139        };
140        
141        // Get messages
142        let messages = match orchestrator.database().conversations.get_session_messages(&session_id, None, None) {
143            Ok(msgs) => msgs.into_iter()
144                .map(|msg| MessageResponse {
145                    role: msg.role,
146                    content: msg.content,
147                })
148                .collect(),
149            Err(e) => {
150                error!("Failed to fetch messages: {}", e);
151                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response());
152            }
153        };
154        
155        Ok(Json(ConversationDetailResponse {
156            id: session.id,
157            title: session.metadata.title.unwrap_or_else(|| "New Chat".to_string()),
158            messages,
159        }))
160    } else {
161        error!("Context orchestrator not initialized");
162        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
163    }
164}
165
166/// Request to update a conversation's title
167#[derive(Debug, Deserialize)]
168pub struct UpdateTitleRequest {
169    pub title: String,
170}
171
172/// Update a conversation's title
173pub async fn update_conversation_title(
174    State(state): State<UnifiedAppState>,
175    Path(session_id): Path<String>,
176    Json(req): Json<UpdateTitleRequest>,
177) -> Result<Json<Value>, Response> {
178    info!("Updating title for conversation: {}", session_id);
179    
180    if req.title.is_empty() {
181        return Err((StatusCode::BAD_REQUEST, "Title cannot be empty").into_response());
182    }
183    
184    let orchestrator_lock = state.context_orchestrator.read().await;
185    
186    if let Some(ref orchestrator) = *orchestrator_lock {
187        match orchestrator.database().conversations.update_session_title(&session_id, &req.title) {
188            Ok(_) => {
189                info!("Successfully updated title for conversation: {}", session_id);
190                Ok(Json(serde_json::json!({
191                    "success": true,
192                    "id": session_id,
193                    "title": req.title
194                })))
195            }
196            Err(e) => {
197                // Standardize on 500 so the frontend handles all DB failures uniformly
198                error!("Failed to update conversation title for session {}: {}", session_id, e);
199                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
200            }
201        }
202    } else {
203        error!("Context orchestrator not initialized");
204        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
205    }
206}
207
208/// Delete a conversation permanently from the database
209/// Called via DELETE /conversations/:id from frontend
210/// Returns success JSON or error status code with message
211pub async fn delete_conversation(
212    State(state): State<UnifiedAppState>,
213    Path(session_id): Path<String>,
214) -> Result<Json<Value>, Response> {
215    info!("Deleting conversation: {}", session_id);
216    
217    let orchestrator_lock = state.context_orchestrator.read().await;
218    
219    if let Some(ref orchestrator) = *orchestrator_lock {
220        match orchestrator.database().conversations.delete_session(&session_id) {
221            Ok(deleted_count) => {
222                if deleted_count == 0 {
223                    info!("Conversation not found for deletion: {}", session_id);
224                    Err((StatusCode::NOT_FOUND, format!("Conversation not found: {}", session_id)).into_response())
225                } else {
226                    info!("Successfully deleted conversation: {}", session_id);
227                    Ok(Json(serde_json::json!({
228                        "success": true,
229                        "id": session_id
230                    })))
231                }
232            }
233            Err(e) => {
234                error!("Failed to delete conversation: {}", e);
235                // Return detailed error to help with debugging
236                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
237            }
238        }
239    } else {
240        error!("Context orchestrator not initialized");
241        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
242    }
243}
244
245/// DB-level statistics response for the Conversations (memory.db) Tier 4 view
246#[derive(Debug, Serialize)]
247pub struct ConversationsDbStatsResponse {
248    /// Absolute path to memory.db on disk
249    pub db_path: String,
250    /// File size in bytes (0 if in-memory DB)
251    pub db_size_bytes: u64,
252    /// Human-readable file size
253    pub db_size_human: String,
254    /// Total number of chat sessions ever recorded
255    pub total_sessions: i64,
256    /// Total messages stored across all sessions
257    pub total_messages: i64,
258    /// Total KV snapshots persisted (Tier 2 of the KV cache hierarchy)
259    pub total_kv_snapshots: i64,
260    /// Total embedding vectors stored for semantic search
261    pub total_embeddings: i64,
262    /// Total detail records (structured facts extracted from conversations)
263    pub total_details: i64,
264    /// Total summary records
265    pub total_summaries: i64,
266    /// Most recently accessed session timestamp (RFC-3339) or null
267    pub last_accessed: Option<String>,
268}
269
270fn format_bytes_local(bytes: u64) -> String {
271    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
272    if bytes == 0 { return "0 B".to_string(); }
273    let i = (bytes as f64).log(1024.0).floor() as usize;
274    let i = i.min(UNITS.len() - 1);
275    let val = bytes as f64 / 1024_f64.powi(i as i32);
276    format!("{:.1} {}", val, UNITS[i])
277}
278
279/// GET /conversations/db-stats — returns memory.db statistics for the Tier 4 view
280pub async fn get_conversations_db_stats(
281    State(state): State<UnifiedAppState>,
282) -> Result<Json<ConversationsDbStatsResponse>, Response> {
283    info!("Fetching conversations DB stats");
284
285    let orchestrator_lock = state.context_orchestrator.read().await;
286
287    let (
288        total_sessions,
289        total_messages,
290        total_kv_snapshots,
291        total_embeddings,
292        total_details,
293        total_summaries,
294        last_accessed,
295    ) = if let Some(ref orchestrator) = *orchestrator_lock {
296        let db = orchestrator.database();
297        let conn_result = db.conversations.get_conn_public();
298        match conn_result {
299            Ok(conn) => {
300                let count = |sql: &str| -> i64 {
301                    conn.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap_or(0)
302                };
303                let sessions    = count("SELECT COUNT(*) FROM sessions");
304                let messages    = count("SELECT COUNT(*) FROM messages");
305                let snapshots   = count("SELECT COUNT(*) FROM kv_snapshots");
306                let embeddings  = count("SELECT COUNT(*) FROM embeddings");
307                let details     = count("SELECT COUNT(*) FROM details");
308                let summaries   = count("SELECT COUNT(*) FROM summaries");
309                let last: Option<String> = conn
310                    .query_row(
311                        "SELECT MAX(last_accessed) FROM sessions",
312                        [],
313                        |r| r.get(0),
314                    )
315                    .unwrap_or(None);
316                (sessions, messages, snapshots, embeddings, details, summaries, last)
317            }
318            Err(e) => {
319                error!("Failed to get DB connection for stats: {}", e);
320                (0, 0, 0, 0, 0, 0, None)
321            }
322        }
323    } else {
324        error!("Context orchestrator not initialized");
325        return Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response());
326    };
327
328    // Resolve the DB file path (same logic as thread_server)
329    let db_path = dirs::data_dir()
330        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
331        .join("Aud.io")
332        .join("data")
333        .join("memory.db");
334
335    let db_size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
336
337    Ok(Json(ConversationsDbStatsResponse {
338        db_path: db_path.to_string_lossy().to_string(),
339        db_size_bytes,
340        db_size_human: format_bytes_local(db_size_bytes),
341        total_sessions,
342        total_messages,
343        total_kv_snapshots,
344        total_embeddings,
345        total_details,
346        total_summaries,
347        last_accessed,
348    }))
349}
350
351/// Request to update a conversation's pinned status
352#[derive(Debug, Deserialize)]
353pub struct UpdatePinnedRequest {
354    pub pinned: bool,
355}
356
357/// Update a conversation's pinned status
358pub async fn update_conversation_pinned(
359    State(state): State<UnifiedAppState>,
360    Path(session_id): Path<String>,
361    Json(req): Json<UpdatePinnedRequest>,
362) -> Result<Json<Value>, Response> {
363    info!("Updating pinned status for conversation: {} to {}", session_id, req.pinned);
364    
365    let orchestrator_lock = state.context_orchestrator.read().await;
366    
367    if let Some(ref orchestrator) = *orchestrator_lock {
368        match orchestrator.database().conversations.update_session_pinned(&session_id, req.pinned) {
369            Ok(_) => {
370                info!("Successfully updated pinned status for conversation: {}", session_id);
371                Ok(Json(serde_json::json!({
372                    "success": true,
373                    "id": session_id,
374                    "pinned": req.pinned
375                })))
376            }
377            Err(e) => {
378                let error_msg = e.to_string();
379                // Check if the error is due to session not found
380                if error_msg.contains("not found") {
381                    error!("Conversation not found: {}", session_id);
382                    Err((StatusCode::NOT_FOUND, format!("Conversation not found: {}", session_id)).into_response())
383                } else {
384                    error!("Failed to update conversation pinned status: {}", e);
385                    Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
386                }
387            }
388        }
389    } else {
390        error!("Context orchestrator not initialized");
391        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
392    }
393}