use chrono::Utc; use db::sqlx; use model::agent::AgentConversationModel; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::error::AppError; use crate::AppService; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateConversation { pub title: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateConversation { pub title: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ConversationResponse { pub id: Uuid, pub session_id: Uuid, pub title: String, pub created_by: Uuid, pub last_message_at: Option>, pub archived_at: Option>, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ToolCallResponse { pub id: String, pub name: String, pub arguments: serde_json::Value, pub output: Option, pub error: Option, pub status: String, pub elapsed_ms: Option, } impl From for ToolCallResponse { fn from(m: model::agent::AgentToolCallLogModel) -> Self { Self { id: m.tool_call_id.unwrap_or_default(), name: m.tool_name, arguments: m.arguments.as_deref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(), output: m.result.as_deref().and_then(|s| serde_json::from_str(s).ok()), error: m.error, status: m.status, elapsed_ms: m.latency_ms, } } } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct MessageResponse { pub id: Uuid, pub conversation_id: Uuid, pub parent_id: Option, pub role: String, pub author: Option, pub content: String, pub content_type: String, pub status: String, pub model_invocation: Option, pub reasoning_content: Option, #[serde(default)] pub tool_calls: Vec, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, sqlx::FromRow)] struct ConversationWithSessionRow { pub id: Uuid, pub session: Uuid, pub title: String, pub created_by: Uuid, pub last_message_at: Option>, pub archived_at: Option>, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub session_name: Option, #[allow(dead_code)] pub session_wk: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ConversationWithSessionResponse { pub id: Uuid, pub session_id: Uuid, pub session_name: Option, pub title: String, pub created_by: Uuid, pub last_message_at: Option>, pub archived_at: Option>, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } impl From for ConversationWithSessionResponse { fn from(r: ConversationWithSessionRow) -> Self { Self { id: r.id, session_id: r.session, session_name: r.session_name, title: r.title, created_by: r.created_by, last_message_at: r.last_message_at, archived_at: r.archived_at, created_at: r.created_at, updated_at: r.updated_at, } } } impl AppService { pub(crate) async fn agent_require_conversation_access( &self, user_id: Uuid, conversation_id: Uuid, ) -> Result { let conv = sqlx::query_as::<_, AgentConversationModel>( "SELECT id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at \ FROM agent_conversation \ WHERE id = $1 AND deleted_at IS NULL", ) .bind(conversation_id) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("conversation not found".to_string()))?; let session: (Option, Option) = sqlx::query_as( "SELECT \"user\", wk \ FROM agent_session \ WHERE id = $1 AND deleted_at IS NULL AND enabled = true", ) .bind(conv.session) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("agent session not found".to_string()))?; let (session_user, session_wk) = session; if session_user != Some(user_id) { if let Some(wk) = session_wk { let _ = crate::AppService::workspace_require_member( &*self, wk, user_id, ) .await?; } else { return Err(AppError::PermissionDenied); } } Ok(conv) } async fn agent_require_session_access( &self, user_id: Uuid, session_id: Uuid, ) -> Result<(), AppError> { let session: (Option, Option) = sqlx::query_as( "SELECT \"user\", wk \ FROM agent_session \ WHERE id = $1 AND deleted_at IS NULL AND enabled = true", ) .bind(session_id) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("agent session not found".to_string()))?; if session.0 != Some(user_id) { if let Some(wk) = session.1 { let _ = crate::AppService::workspace_require_member( &*self, wk, user_id, ) .await?; } else { return Err(AppError::PermissionDenied); } } Ok(()) } } impl AppService { pub async fn agent_conversation_create( &self, user_id: Uuid, session_id: Uuid, params: CreateConversation, ) -> Result { self.agent_require_session_access(user_id, session_id) .await?; let id = Uuid::now_v7(); let now = Utc::now(); let row = sqlx::query_as::<_, AgentConversationModel>( "INSERT INTO agent_conversation \ (id, session, title, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $5) \ RETURNING id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at", ) .bind(id) .bind(session_id) .bind(¶ms.title) .bind(user_id) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(row.into()) } pub async fn agent_conversation_list( &self, user_id: Uuid, session_id: Uuid, ) -> Result, AppError> { self.agent_require_session_access(user_id, session_id) .await?; let rows = sqlx::query_as::<_, AgentConversationModel>( "SELECT id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at \ FROM agent_conversation \ WHERE session = $1 AND deleted_at IS NULL \ ORDER BY last_message_at DESC NULLS LAST, created_at DESC \ LIMIT 100", ) .bind(session_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn agent_conversation_list_all( &self, user_id: Uuid, wk: Option<&str>, ) -> Result, AppError> { let rows: Vec = if let Some(wk_name) = wk { sqlx::query_as( "SELECT c.id, c.session, c.title, c.created_by, c.last_message_at, \ c.archived_at, c.created_at, c.updated_at, \ s.name as session_name, s.wk as session_wk \ FROM agent_conversation c \ INNER JOIN agent_session s ON s.id = c.session \ WHERE c.deleted_at IS NULL AND s.deleted_at IS NULL AND s.enabled = true \ AND (s.\"user\" = $1 OR (s.wk = (SELECT id FROM workspace WHERE name = $2) AND s.wk IN (SELECT wk FROM wk_member WHERE \"user\" = $1))) \ ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC \ LIMIT 100", ) .bind(user_id) .bind(wk_name) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? } else { sqlx::query_as( "SELECT c.id, c.session, c.title, c.created_by, c.last_message_at, \ c.archived_at, c.created_at, c.updated_at, \ s.name as session_name, s.wk as session_wk \ FROM agent_conversation c \ INNER JOIN agent_session s ON s.id = c.session \ WHERE c.deleted_at IS NULL AND s.deleted_at IS NULL AND s.enabled = true \ AND s.\"user\" = $1 \ ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC \ LIMIT 100", ) .bind(user_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? }; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn agent_conversation_get( &self, user_id: Uuid, conversation_id: Uuid, ) -> Result { Ok(self .agent_require_conversation_access(user_id, conversation_id) .await? .into()) } pub async fn agent_conversation_update( &self, user_id: Uuid, conversation_id: Uuid, params: UpdateConversation, ) -> Result { let existing = self .agent_require_conversation_access(user_id, conversation_id) .await?; let title = params.title.unwrap_or(existing.title); let now = Utc::now(); let row = sqlx::query_as::<_, AgentConversationModel>( "UPDATE agent_conversation SET title = $1, updated_at = $2 \ WHERE id = $3 AND deleted_at IS NULL \ RETURNING id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at", ) .bind(&title) .bind(now) .bind(conversation_id) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(row.into()) } pub async fn agent_conversation_delete( &self, user_id: Uuid, conversation_id: Uuid, ) -> Result<(), AppError> { self.agent_require_conversation_access(user_id, conversation_id) .await?; let now = Utc::now(); let rows = sqlx::query( "UPDATE agent_conversation SET deleted_at = $1, updated_at = $1 \ WHERE id = $2 AND deleted_at IS NULL", ) .bind(now) .bind(conversation_id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if rows.rows_affected() == 0 { return Err(AppError::NotFound("conversation not found".to_string())); } Ok(()) } pub async fn agent_conversation_archive( &self, user_id: Uuid, conversation_id: Uuid, ) -> Result { self.agent_require_conversation_access(user_id, conversation_id) .await?; let now = Utc::now(); let row = sqlx::query_as::<_, AgentConversationModel>( "UPDATE agent_conversation SET archived_at = $1, updated_at = $1 \ WHERE id = $2 AND deleted_at IS NULL \ RETURNING id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at", ) .bind(now) .bind(conversation_id) .fetch_optional(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("conversation not found".to_string()))?; Ok(row.into()) } pub async fn agent_conversation_unarchive( &self, user_id: Uuid, conversation_id: Uuid, ) -> Result { self.agent_require_conversation_access(user_id, conversation_id) .await?; let now = Utc::now(); let row = sqlx::query_as::<_, AgentConversationModel>( "UPDATE agent_conversation SET archived_at = NULL, updated_at = $1 \ WHERE id = $2 AND deleted_at IS NULL \ RETURNING id, session, title, created_by, last_message_at, \ archived_at, compacted_summary, created_at, updated_at, deleted_at", ) .bind(now) .bind(conversation_id) .fetch_optional(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("conversation not found".to_string()))?; Ok(row.into()) } } impl AppService { pub async fn agent_message_list( &self, user_id: Uuid, conversation_id: Uuid, limit: Option, before: Option, ) -> Result, AppError> { self.agent_require_conversation_access(user_id, conversation_id) .await?; let limit = limit.unwrap_or(50).min(100) as i64; let rows = if let Some(before_id) = before { sqlx::query_as::<_, model::agent::AgentMessageModel>( "SELECT id, conversation, parent, role, author, content, content_type, \ status, model_invocation, reasoning_content, created_at, updated_at, deleted_at \ FROM agent_message \ WHERE conversation = $1 AND deleted_at IS NULL \ AND created_at < (SELECT created_at FROM agent_message WHERE id = $2 AND conversation = $1) \ ORDER BY created_at DESC LIMIT $3", ) .bind(conversation_id) .bind(before_id) .bind(limit) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? } else { sqlx::query_as::<_, model::agent::AgentMessageModel>( "SELECT id, conversation, parent, role, author, content, content_type, \ status, model_invocation, reasoning_content, created_at, updated_at, deleted_at \ FROM agent_message \ WHERE conversation = $1 AND deleted_at IS NULL \ ORDER BY created_at DESC LIMIT $2", ) .bind(conversation_id) .bind(limit) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? }; // Fetch tool calls for all assistant messages in one query. let message_ids: Vec = rows.iter().map(|r| r.id).collect(); let tool_call_logs = if !message_ids.is_empty() { sqlx::query_as::<_, model::agent::AgentToolCallLogModel>( "SELECT id, invocation, session, conversation, message, tool_call_id, \ tool_name, arguments, result, error, status, \ started_at, finished_at, latency_ms \ FROM agent_tool_call_log \ WHERE message = ANY($1) \ ORDER BY started_at ASC", ) .bind(&message_ids) .fetch_all(self.db.reader()) .await .unwrap_or_default() } else { Vec::new() }; // Group tool calls by message_id. let mut tool_calls_by_message: std::collections::HashMap> = std::collections::HashMap::new(); for log in tool_call_logs { if let Some(msg_id) = log.message { tool_calls_by_message .entry(msg_id) .or_default() .push(log.into()); } } let mut messages: Vec = rows .into_iter() .map(|row| { let mut msg: MessageResponse = row.into(); msg.tool_calls = tool_calls_by_message .remove(&msg.id) .unwrap_or_default(); msg }) .collect(); messages.reverse(); Ok(messages) } pub async fn agent_conversation_fork( &self, user_id: Uuid, source_conversation_id: Uuid, up_to_message_id: Option, title_override: Option<&str>, ) -> Result { let source = self .agent_require_conversation_access(user_id, source_conversation_id) .await?; let session_id = source.session; let base_title = title_override .map(|t| t.to_string()) .unwrap_or_else(|| format!("{} (fork)", source.title)); let new_id = Uuid::now_v7(); let now = Utc::now(); sqlx::query( "INSERT INTO agent_conversation \ (id, session, title, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $5)", ) .bind(new_id) .bind(session_id) .bind(&base_title) .bind(user_id) .bind(now) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let messages = if let Some(msg_id) = up_to_message_id { sqlx::query_as::<_, model::agent::AgentMessageModel>( "SELECT id, conversation, parent, role, author, content, content_type, \ status, model_invocation, reasoning_content, \ created_at, updated_at, deleted_at \ FROM agent_message \ WHERE conversation = $1 \ AND deleted_at IS NULL \ AND created_at <= (SELECT created_at FROM agent_message WHERE id = $2) \ ORDER BY created_at ASC", ) .bind(source_conversation_id) .bind(msg_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? } else { sqlx::query_as::<_, model::agent::AgentMessageModel>( "SELECT id, conversation, parent, role, author, content, content_type, \ status, model_invocation, reasoning_content, \ created_at, updated_at, deleted_at \ FROM agent_message \ WHERE conversation = $1 AND deleted_at IS NULL \ ORDER BY created_at ASC", ) .bind(source_conversation_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? }; for msg in &messages { let new_msg_id = Uuid::now_v7(); sqlx::query( "INSERT INTO agent_message \ (id, conversation, parent, role, author, content, content_type, \ status, model_invocation, reasoning_content, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)", ) .bind(new_msg_id) .bind(new_id) .bind::>(None) .bind(&msg.role) .bind(msg.author) .bind(&msg.content) .bind(&msg.content_type) .bind(&msg.status) .bind(msg.model_invocation) .bind(&msg.reasoning_content) .bind(msg.created_at) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let _ = sqlx::query( "INSERT INTO agent_message_fork \ (source_message, forked_conversation, forked_by, created_at) \ VALUES ($1, $2, $3, $4)", ) .bind(msg.id) .bind(new_id) .bind(user_id) .bind(now) .execute(self.db.writer()) .await; } self.agent_conversation_get(user_id, new_id).await } } impl From for ConversationResponse { fn from(m: AgentConversationModel) -> Self { Self { id: m.id, session_id: m.session, title: m.title, created_by: m.created_by, last_message_at: m.last_message_at, archived_at: m.archived_at, created_at: m.created_at, updated_at: m.updated_at, } } } impl From for MessageResponse { fn from(m: model::agent::AgentMessageModel) -> Self { Self { id: m.id, conversation_id: m.conversation, parent_id: m.parent, role: m.role, author: m.author, content: m.content, content_type: m.content_type, status: m.status, model_invocation: m.model_invocation, reasoning_content: m.reasoning_content, tool_calls: Vec::new(), created_at: m.created_at, updated_at: m.updated_at, } } }