Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/aionui-api-types/src/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub struct ListConversationsQuery {
pub source: Option<String>,
pub cron_job_id: Option<String>,
pub pinned: Option<bool>,
pub workspace: Option<String>,
}

/// Query parameters for `GET /api/conversations/:id/messages`.
Expand Down Expand Up @@ -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<ConversationProjectResponse>;

/// Paginated list of conversations.
pub type ConversationListResponse = PaginatedResult<ConversationResponse>;

Expand Down
9 changes: 5 additions & 4 deletions crates/aionui-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions crates/aionui-conversation/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,6 +109,7 @@ impl From<ConversationError> 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))
Expand Down Expand Up @@ -140,6 +142,14 @@ async fn create(
Ok((StatusCode::CREATED, Json(ApiResponse::ok(conversation))))
}

async fn list_projects(
State(state): State<ConversationRouterState>,
Extension(user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<ConversationProjectListResponse>>, 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<ConversationRouterState>,
Extension(user): Extension<CurrentUser>,
Expand Down
27 changes: 22 additions & 5 deletions crates/aionui-conversation/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ConversationProjectListResponse, ConversationError> {
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(
Expand All @@ -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?;
Expand Down
4 changes: 2 additions & 2 deletions crates/aionui-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
14 changes: 14 additions & 0 deletions crates/aionui-db/src/repository/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<ConversationRow>, DbError>;

/// Lists custom workspaces with conversation counts and latest activity.
async fn list_projects(&self, _user_id: &str) -> Result<Vec<ConversationProjectRow>, 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<Vec<ConversationRow>, DbError>;
Expand Down Expand Up @@ -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 {
Expand All @@ -242,6 +254,8 @@ pub struct ConversationFilters {
pub cron_job_id: Option<String>,
/// Filter by pinned status.
pub pinned: Option<bool>,
/// Filter by the exact `extra.workspace` value.
pub workspace: Option<String>,
}

impl ConversationFilters {
Expand Down
94 changes: 92 additions & 2 deletions crates/aionui-db/src/repository/sqlite_conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -323,6 +323,28 @@ impl IConversationRepository for SqliteConversationRepository {

// ── Extended queries ────────────────────────────────────────────

async fn list_projects(&self, user_id: &str) -> Result<Vec<ConversationProjectRow>, 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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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::<std::collections::HashMap<_, _>>();
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;
Expand Down
Loading