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                    // Only include sessions that have a title (completed chats)
62                    // Skip sessions without titles - these are in-progress and not ready to show
63                    if let Some(ref title) = session.metadata.title {
64                        // Get message count for this session using COUNT query
65                        let message_count = orchestrator.database().conversations
66                            .get_session_message_count(&session.id)
67                            .unwrap_or(0);
68                        
69                        conversations.push(ConversationSummary {
70                            id: session.id.clone(),
71                            title: title.clone(),
72                            created_at: session.created_at.to_rfc3339(),
73                            last_accessed: session.last_accessed.to_rfc3339(),
74                            message_count,
75                            pinned: session.metadata.pinned,
76                        });
77                    }
78                }
79                
80                info!("Found {} conversations", conversations.len());
81                Ok(Json(ConversationsResponse { conversations }))
82            }
83            Err(e) => {
84                error!("Failed to fetch conversations: {}", e);
85                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
86            }
87        }
88    } else {
89        error!("Context orchestrator not initialized");
90        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
91    }
92}
93
94/// Fetch a specific conversation's messages
95pub async fn get_conversation(
96    State(state): State<UnifiedAppState>,
97    Path(session_id): Path<String>,
98) -> Result<Json<ConversationDetailResponse>, Response> {
99    info!("Fetching conversation: {}", session_id);
100    
101    let orchestrator_lock = state.context_orchestrator.read().await;
102    
103    if let Some(ref orchestrator) = *orchestrator_lock {
104        // Get session metadata
105        let session = match orchestrator.database().conversations.get_session(&session_id) {
106            Ok(Some(s)) => s,
107            Ok(None) => {
108                return Err((StatusCode::NOT_FOUND, "Conversation not found").into_response());
109            }
110            Err(e) => {
111                error!("Failed to fetch session: {}", e);
112                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response());
113            }
114        };
115        
116        // Get messages
117        let messages = match orchestrator.database().conversations.get_session_messages(&session_id, None, None) {
118            Ok(msgs) => msgs.into_iter()
119                .map(|msg| MessageResponse {
120                    role: msg.role,
121                    content: msg.content,
122                })
123                .collect(),
124            Err(e) => {
125                error!("Failed to fetch messages: {}", e);
126                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response());
127            }
128        };
129        
130        Ok(Json(ConversationDetailResponse {
131            id: session.id,
132            title: session.metadata.title.unwrap_or_else(|| "New Chat".to_string()),
133            messages,
134        }))
135    } else {
136        error!("Context orchestrator not initialized");
137        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
138    }
139}
140
141/// Request to update a conversation's title
142#[derive(Debug, Deserialize)]
143pub struct UpdateTitleRequest {
144    pub title: String,
145}
146
147/// Update a conversation's title
148pub async fn update_conversation_title(
149    State(state): State<UnifiedAppState>,
150    Path(session_id): Path<String>,
151    Json(req): Json<UpdateTitleRequest>,
152) -> Result<Json<Value>, Response> {
153    info!("Updating title for conversation: {}", session_id);
154    
155    if req.title.is_empty() {
156        return Err((StatusCode::BAD_REQUEST, "Title cannot be empty").into_response());
157    }
158    
159    let orchestrator_lock = state.context_orchestrator.read().await;
160    
161    if let Some(ref orchestrator) = *orchestrator_lock {
162        match orchestrator.database().conversations.update_session_title(&session_id, &req.title) {
163            Ok(_) => {
164                info!("Successfully updated title for conversation: {}", session_id);
165                Ok(Json(serde_json::json!({
166                    "success": true,
167                    "id": session_id,
168                    "title": req.title
169                })))
170            }
171            Err(e) => {
172                // Standardize on 500 so the frontend handles all DB failures uniformly
173                error!("Failed to update conversation title for session {}: {}", session_id, e);
174                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
175            }
176        }
177    } else {
178        error!("Context orchestrator not initialized");
179        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
180    }
181}
182
183/// Delete a conversation permanently from the database
184/// Called via DELETE /conversations/:id from frontend
185/// Returns success JSON or error status code with message
186pub async fn delete_conversation(
187    State(state): State<UnifiedAppState>,
188    Path(session_id): Path<String>,
189) -> Result<Json<Value>, Response> {
190    info!("Deleting conversation: {}", session_id);
191    
192    let orchestrator_lock = state.context_orchestrator.read().await;
193    
194    if let Some(ref orchestrator) = *orchestrator_lock {
195        match orchestrator.database().conversations.delete_session(&session_id) {
196            Ok(deleted_count) => {
197                if deleted_count == 0 {
198                    info!("Conversation not found for deletion: {}", session_id);
199                    Err((StatusCode::NOT_FOUND, format!("Conversation not found: {}", session_id)).into_response())
200                } else {
201                    info!("Successfully deleted conversation: {}", session_id);
202                    Ok(Json(serde_json::json!({
203                        "success": true,
204                        "id": session_id
205                    })))
206                }
207            }
208            Err(e) => {
209                error!("Failed to delete conversation: {}", e);
210                // Return detailed error to help with debugging
211                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
212            }
213        }
214    } else {
215        error!("Context orchestrator not initialized");
216        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
217    }
218}
219
220/// Request to update a conversation's pinned status
221#[derive(Debug, Deserialize)]
222pub struct UpdatePinnedRequest {
223    pub pinned: bool,
224}
225
226/// Update a conversation's pinned status
227pub async fn update_conversation_pinned(
228    State(state): State<UnifiedAppState>,
229    Path(session_id): Path<String>,
230    Json(req): Json<UpdatePinnedRequest>,
231) -> Result<Json<Value>, Response> {
232    info!("Updating pinned status for conversation: {} to {}", session_id, req.pinned);
233    
234    let orchestrator_lock = state.context_orchestrator.read().await;
235    
236    if let Some(ref orchestrator) = *orchestrator_lock {
237        match orchestrator.database().conversations.update_session_pinned(&session_id, req.pinned) {
238            Ok(_) => {
239                info!("Successfully updated pinned status for conversation: {}", session_id);
240                Ok(Json(serde_json::json!({
241                    "success": true,
242                    "id": session_id,
243                    "pinned": req.pinned
244                })))
245            }
246            Err(e) => {
247                let error_msg = e.to_string();
248                // Check if the error is due to session not found
249                if error_msg.contains("not found") {
250                    error!("Conversation not found: {}", session_id);
251                    Err((StatusCode::NOT_FOUND, format!("Conversation not found: {}", session_id)).into_response())
252                } else {
253                    error!("Failed to update conversation pinned status: {}", e);
254                    Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response())
255                }
256            }
257        }
258    } else {
259        error!("Context orchestrator not initialized");
260        Err((StatusCode::SERVICE_UNAVAILABLE, "Memory system not available").into_response())
261    }
262}