diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index 3f868750b..85162b8c8 100644 --- a/crates/aionui-api-types/src/conversation.rs +++ b/crates/aionui-api-types/src/conversation.rs @@ -168,6 +168,7 @@ pub struct ListConversationsQuery { pub source: Option, pub cron_job_id: Option, pub pinned: Option, + pub workspace: Option, } /// Query parameters for `GET /api/conversations/:id/messages`. @@ -232,6 +233,17 @@ pub struct ConversationResponse { pub extra: serde_json::Value, } +/// Project summary derived from custom-workspace conversations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConversationProjectResponse { + pub workspace: String, + pub latest_conversation_at: TimestampMs, + pub conversation_count: u64, +} + +/// List of conversation projects ordered by recent activity. +pub type ConversationProjectListResponse = Vec; + /// Paginated list of conversations. pub type ConversationListResponse = PaginatedResult; diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 4acca5e33..77f179e53 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -78,10 +78,11 @@ pub use conversation::{ CancelConversationRequest, CancelConversationResponse, CloneConversationRequest, ConversationArtifactKind, ConversationArtifactListResponse, ConversationArtifactResponse, ConversationArtifactStatus, ConversationAssistantIdentityResponse, ConversationListResponse, ConversationMcpStatus, ConversationMcpStatusKind, - ConversationResponse, ConversationRuntimeStateKind, ConversationRuntimeSummary, CreateConversationRequest, - EnsureConversationRuntimeResponse, ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, - MessageSearchItem, MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, - UpdateConversationArtifactRequest, UpdateConversationRequest, + ConversationProjectListResponse, ConversationProjectResponse, ConversationResponse, ConversationRuntimeStateKind, + ConversationRuntimeSummary, CreateConversationRequest, EnsureConversationRuntimeResponse, ListConversationsQuery, + ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchItem, MessageSearchResponse, + SearchMessagesQuery, SendMessageRequest, SendMessageResponse, UpdateConversationArtifactRequest, + UpdateConversationRequest, }; pub use cron::{ CreateConversationCronRequest, CreateConversationCronResponse, CreateCronJobRequest, CronAgentConfigReadDto, diff --git a/crates/aionui-conversation/src/routes.rs b/crates/aionui-conversation/src/routes.rs index bbb06d2b3..481b9f02c 100644 --- a/crates/aionui-conversation/src/routes.rs +++ b/crates/aionui-conversation/src/routes.rs @@ -9,10 +9,11 @@ use axum::routing::{get, patch, post}; use aionui_api_types::{ ActiveCountResponse, ApiResponse, ApprovalCheckQuery, ApprovalCheckResponse, CancelConversationRequest, CancelConversationResponse, CloneConversationRequest, ConfirmRequest, ConfirmationListResponse, - ConversationArtifactListResponse, ConversationArtifactResponse, ConversationListResponse, ConversationResponse, - CreateConversationRequest, EnsureConversationRuntimeResponse, ListConversationsQuery, ListMessagesQuery, - MessageListResponse, MessageResponse, MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, - SendMessageResponse, UpdateConversationArtifactRequest, UpdateConversationRequest, + ConversationArtifactListResponse, ConversationArtifactResponse, ConversationListResponse, + ConversationProjectListResponse, ConversationResponse, CreateConversationRequest, + EnsureConversationRuntimeResponse, ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, + MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, + UpdateConversationArtifactRequest, UpdateConversationRequest, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -108,6 +109,7 @@ impl From for ApiError { pub fn conversation_routes(state: ConversationRouterState) -> Router { Router::new() .route("/api/conversations", post(create).get(list)) + .route("/api/conversation-projects", get(list_projects)) .route("/api/conversations/{id}", get(get_one).patch(update).delete(delete_one)) .route("/api/conversations/{id}/reset", post(reset)) .route("/api/conversations/{id}/associated", get(associated)) @@ -140,6 +142,14 @@ async fn create( Ok((StatusCode::CREATED, Json(ApiResponse::ok(conversation)))) } +async fn list_projects( + State(state): State, + Extension(user): Extension, +) -> Result>, ApiError> { + let result = state.service.list_projects(&user.id).await.map_err(ApiError::from)?; + Ok(Json(ApiResponse::ok(result))) +} + async fn list( State(state): State, Extension(user): Extension, diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 43025c66d..8a1bafa6e 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -17,11 +17,12 @@ use aionui_api_types::{ ApprovalCheckResponse, AssistantConversationOverridesRequest, CancelConversationResponse, CloneConversationRequest, ConfirmRequest, ConfirmationListResponse, ConversationArtifactKind, ConversationArtifactListResponse, ConversationArtifactResponse, ConversationArtifactStatus, ConversationListResponse, ConversationMcpStatus, - ConversationMcpStatusKind, ConversationResponse, ConversationRuntimeSummary, CreateConversationRequest, - EnsureConversationRuntimeResponse, ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, - MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, SessionMcpServer, - SessionMcpTransport, TeamSessionBinding, UpdateConversationArtifactRequest, UpdateConversationRequest, - WebSocketMessage, assistant_avatar_response_value, assistant_avatar_response_value_with_version, + ConversationMcpStatusKind, ConversationProjectListResponse, ConversationProjectResponse, ConversationResponse, + ConversationRuntimeSummary, CreateConversationRequest, EnsureConversationRuntimeResponse, ListConversationsQuery, + ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchResponse, SearchMessagesQuery, + SendMessageRequest, SendMessageResponse, SessionMcpServer, SessionMcpTransport, TeamSessionBinding, + UpdateConversationArtifactRequest, UpdateConversationRequest, WebSocketMessage, assistant_avatar_response_value, + assistant_avatar_response_value_with_version, }; use aionui_common::{ AgentKillReason, AgentType, ConversationSource, ConversationStatus, ErrorChain, MessageType, OnConversationDelete, @@ -1743,6 +1744,21 @@ impl ConversationService { Ok(response) } + /// List custom-workspace projects with counts and latest activity. + #[tracing::instrument(skip_all, fields(user_id = %user_id))] + pub async fn list_projects(&self, user_id: &str) -> Result { + let rows = self.conversation_repo.list_projects(user_id).await?; + Ok(rows + .into_iter() + .filter(|row| !std::path::Path::new(&row.workspace).starts_with(&self.workspace_root)) + .map(|row| ConversationProjectResponse { + workspace: row.workspace, + latest_conversation_at: row.latest_conversation_at, + conversation_count: row.conversation_count.max(0) as u64, + }) + .collect()) + } + /// List conversations with cursor-based pagination and optional filters. #[tracing::instrument(skip_all, fields(user_id = %user_id))] pub async fn list( @@ -1756,6 +1772,7 @@ impl ConversationService { source: query.source, cron_job_id: query.cron_job_id, pinned: query.pinned, + workspace: query.workspace, }; let result = self.conversation_repo.list_paginated(user_id, &filters).await?; diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index ee3a776b0..3ab8fae68 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -26,8 +26,8 @@ pub use models::{ }; pub use repository::channel::UpdatePluginStatusParams; pub use repository::conversation::{ - ConversationFilters, ConversationRowUpdate, MessagePageCursor, MessagePageDirection, MessagePageParams, - MessagePageResult, MessageRowUpdate, MessageSearchRow, + ConversationFilters, ConversationProjectRow, ConversationRowUpdate, MessagePageCursor, MessagePageDirection, + MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, }; pub use repository::cron::UpdateCronJobParams; pub use repository::mcp_server::{CreateMcpServerParams, UpdateMcpServerParams}; diff --git a/crates/aionui-db/src/repository/conversation.rs b/crates/aionui-db/src/repository/conversation.rs index 469dbe537..c9d44d47c 100644 --- a/crates/aionui-db/src/repository/conversation.rs +++ b/crates/aionui-db/src/repository/conversation.rs @@ -52,6 +52,11 @@ pub trait IConversationRepository: Send + Sync { /// Lists conversations whose `extra.cronJobId` matches. async fn list_by_cron_job(&self, user_id: &str, cron_job_id: &str) -> Result, DbError>; + /// Lists custom workspaces with conversation counts and latest activity. + async fn list_projects(&self, _user_id: &str) -> Result, DbError> { + Ok(Vec::new()) + } + /// Lists conversations sharing the same `extra.workspace` value. /// The conversation identified by `conversation_id` is excluded. async fn list_associated(&self, user_id: &str, conversation_id: &str) -> Result, DbError>; @@ -229,6 +234,13 @@ pub struct MessagePageResult { pub has_more_after: bool, } +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct ConversationProjectRow { + pub workspace: String, + pub latest_conversation_at: TimestampMs, + pub conversation_count: i64, +} + /// Filters for paginated conversation listing. #[derive(Debug, Clone, Default)] pub struct ConversationFilters { @@ -242,6 +254,8 @@ pub struct ConversationFilters { pub cron_job_id: Option, /// Filter by pinned status. pub pinned: Option, + /// Filter by the exact `extra.workspace` value. + pub workspace: Option, } impl ConversationFilters { diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index c27aae2a0..d47b11647 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -8,8 +8,8 @@ use crate::models::{ UpsertConversationAssistantSnapshotParams, }; use crate::repository::conversation::{ - ConversationFilters, ConversationRowUpdate, IConversationRepository, MessagePageCursor, MessagePageDirection, - MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, + ConversationFilters, ConversationProjectRow, ConversationRowUpdate, IConversationRepository, MessagePageCursor, + MessagePageDirection, MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, }; /// SQLite-backed implementation of [`IConversationRepository`]. @@ -323,6 +323,28 @@ impl IConversationRepository for SqliteConversationRepository { // ── Extended queries ──────────────────────────────────────────── + async fn list_projects(&self, user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ConversationProjectRow>( + "SELECT json_extract(extra, '$.workspace') AS workspace, \ + MAX(updated_at) AS latest_conversation_at, \ + COUNT(*) AS conversation_count \ + FROM conversations \ + WHERE user_id = ? \ + AND json_extract(extra, '$.workspace') IS NOT NULL \ + AND json_extract(extra, '$.workspace') != '' \ + AND COALESCE(json_extract(extra, '$.is_health_check'), 0) != 1 \ + AND json_extract(extra, '$.team_id') IS NULL \ + AND json_extract(extra, '$.teamId') IS NULL \ + GROUP BY json_extract(extra, '$.workspace') \ + ORDER BY latest_conversation_at DESC, workspace ASC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows) + } + async fn find_by_source_and_chat( &self, user_id: &str, @@ -996,6 +1018,10 @@ fn append_filter_conditions(filters: &ConversationFilters, where_parts: &mut Vec where_parts.push("c.pinned = ?".to_string()); binds.push(BindValue::Bool(pinned)); } + if let Some(ref workspace) = filters.workspace { + where_parts.push("json_extract(c.extra, '$.workspace') = ?".to_string()); + binds.push(BindValue::Str(workspace.clone())); + } } /// Builds a count query and bind values for the total (ignoring cursor). @@ -1072,6 +1098,70 @@ mod tests { // ── Conversation CRUD tests ───────────────────────────────────── + #[tokio::test] + async fn list_projects_groups_legacy_workspaces_and_filters_hidden_rows() { + let (repo, _db) = setup().await; + let mut first = sample_conversation(SYSTEM_USER_ID); + first.extra = r#"{"workspace":"/projects/alpha"}"#.to_string(); + first.updated_at = 10; + let mut second = sample_conversation(SYSTEM_USER_ID); + second.extra = r#"{"workspace":"/projects/alpha"}"#.to_string(); + second.updated_at = 20; + let mut beta = sample_conversation(SYSTEM_USER_ID); + beta.extra = r#"{"workspace":"/projects/beta"}"#.to_string(); + beta.updated_at = 15; + let mut temporary = sample_conversation(SYSTEM_USER_ID); + temporary.extra = r#"{"workspace":"/projects/temp"}"#.to_string(); + let mut health = sample_conversation(SYSTEM_USER_ID); + health.extra = r#"{"workspace":"/projects/health","is_health_check":true}"#.to_string(); + let mut team = sample_conversation(SYSTEM_USER_ID); + team.extra = r#"{"workspace":"/projects/team","team_id":"team-1"}"#.to_string(); + + for row in [&first, &second, &beta, &temporary, &health, &team] { + repo.create(row).await.unwrap(); + } + + let projects = repo.list_projects(SYSTEM_USER_ID).await.unwrap(); + + assert_eq!(projects.len(), 3); + let projects_by_workspace = projects + .into_iter() + .map(|project| (project.workspace.clone(), project)) + .collect::>(); + let alpha = &projects_by_workspace["/projects/alpha"]; + assert_eq!(alpha.conversation_count, 2); + assert_eq!(alpha.latest_conversation_at, 20); + assert_eq!(projects_by_workspace["/projects/beta"].conversation_count, 1); + assert_eq!(projects_by_workspace["/projects/temp"].conversation_count, 1); + } + + #[tokio::test] + async fn list_paginated_filters_exact_workspace() { + let (repo, _db) = setup().await; + let mut alpha = sample_conversation(SYSTEM_USER_ID); + alpha.extra = r#"{"workspace":"/projects/alpha"}"#.to_string(); + let mut beta = sample_conversation(SYSTEM_USER_ID); + beta.extra = r#"{"workspace":"/projects/beta"}"#.to_string(); + repo.create(&alpha).await.unwrap(); + repo.create(&beta).await.unwrap(); + + let result = repo + .list_paginated( + SYSTEM_USER_ID, + &ConversationFilters { + workspace: Some("/projects/alpha".to_string()), + limit: 5, + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(result.total, 1); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].id, alpha.id); + } + #[tokio::test] async fn create_and_get_conversation() { let (repo, _db) = setup().await;