gitdataai/libs/service/chat/conversation.rs

321 lines
11 KiB
Rust

use crate::error::AppError;
use models::ai::{AiConversation, ai_conversation};
use models::projects::MemberRole;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use uuid::Uuid;
use crate::AppService;
impl AppService {
pub async fn find_conversation(
&self,
conversation_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
AiConversation::find_by_id(conversation_id)
.one(self.db.reader())
.await?
.ok_or_else(|| AppError::NotFound("conversation".into()))
}
pub async fn find_conversation_owned(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id != user_id {
// For project conversations, check access control
if c.project_id.is_some() {
let access =
super::access::check_conversation_access(&self.db, &c, user_id).await?;
if access != super::AccessLevel::Denied {
return Ok(c);
}
}
return Err(AppError::PermissionDenied);
}
Ok(c)
}
pub async fn find_conversation_accessible(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id == user_id {
return Ok(c);
}
if c.project_id.is_some() {
let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
if access != super::AccessLevel::Denied {
return Ok(c);
}
}
Err(AppError::PermissionDenied)
}
pub async fn find_conversation_full_access(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id == user_id {
return Ok(c);
}
if c.project_id.is_some() {
let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
if access == super::AccessLevel::Full {
return Ok(c);
}
}
Err(AppError::PermissionDenied)
}
pub async fn find_conversation_creator(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id == user_id {
Ok(c)
} else {
Err(AppError::PermissionDenied)
}
}
pub async fn create_conversation(
&self,
user_id: Uuid,
project_id: Option<Uuid>,
title: Option<String>,
model: String,
model_config: Option<serde_json::Value>,
access_visibility: Option<String>,
can_ask: Option<String>,
model_uid: Option<Uuid>,
model_name: Option<String>,
) -> Result<ai_conversation::Model, AppError> {
let scope = if project_id.is_some() {
// For project chats: check that user can create (owner or admin)
if let Some(pid) = project_id {
let role = super::access::resolve_project_role(&self.db, pid, user_id).await?;
match role {
Some(r) if super::access::can_create(r) => {}
_ => return Err(AppError::PermissionDenied),
}
// Auto-increment project_uid
let next_uid = self.next_project_chat_uid(pid).await?;
let now = chrono::Utc::now();
let conv =
ai_conversation::ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(user_id),
project_id: Set(Some(pid)),
scope: Set("project".to_string()),
title: Set(title),
model: Set(model),
model_config: Set(model_config),
status: Set("active".to_string()),
root_message_id: Set(None),
fork_count: Set(0),
is_shared: Set(false),
message_count: Set(0),
token_usage_total: Set(None),
access_visibility: Set(
access_visibility.unwrap_or_else(|| "owner".to_string())
),
can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())),
project_uid: Set(Some(next_uid)),
model_uid: Set(model_uid),
model_name: Set(model_name),
created_at: Set(now),
updated_at: Set(now),
}
.insert(self.db.writer())
.await?;
observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED);
return Ok(conv);
}
"project".to_string()
} else {
"user".to_string()
};
let now = chrono::Utc::now();
let conv = ai_conversation::ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(user_id),
project_id: Set(project_id),
scope: Set(scope),
title: Set(title),
model: Set(model),
model_config: Set(model_config),
status: Set("active".to_string()),
root_message_id: Set(None),
fork_count: Set(0),
is_shared: Set(false),
message_count: Set(0),
token_usage_total: Set(None),
access_visibility: Set(access_visibility.unwrap_or_else(|| "owner".to_string())),
can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())),
project_uid: Set(None),
model_uid: Set(model_uid),
model_name: Set(model_name),
created_at: Set(now),
updated_at: Set(now),
}
.insert(self.db.writer())
.await?;
observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED);
Ok(conv)
}
/// Get the next project-unique sequential number for chat conversations.
async fn next_project_chat_uid(&self, project_id: Uuid) -> Result<i32, AppError> {
use sea_orm::ExprTrait;
let max_uid: Option<Option<i32>> = AiConversation::find()
.filter(ai_conversation::Column::ProjectId.eq(project_id))
.filter(ai_conversation::Column::ProjectUid.is_not_null())
.select_only()
.column_as(
sea_orm::sea_query::Expr::col(ai_conversation::Column::ProjectUid).max(),
"max_uid",
)
.into_tuple::<Option<i32>>()
.one(self.db.reader())
.await?;
Ok(max_uid.flatten().unwrap_or(0) + 1)
}
pub async fn list_conversations(
&self,
user_id: Uuid,
project_id: Option<Uuid>,
page_size: u64,
search_query: Option<String>,
) -> Result<Vec<ai_conversation::Model>, AppError> {
let mut query = AiConversation::find().order_by_desc(ai_conversation::Column::UpdatedAt);
if let Some(pid) = project_id {
// For project chats, apply visibility rules
let role = super::access::resolve_project_role(&self.db, pid, user_id).await?;
match role {
Some(r) => {
query = query.filter(ai_conversation::Column::ProjectId.eq(pid));
let convs = query
.paginate(self.db.reader(), page_size.saturating_mul(4).max(page_size))
.fetch_page(0)
.await?;
let mut visible = Vec::new();
for conv in convs {
if conv.user_id == user_id || matches!(r, MemberRole::Owner) {
visible.push(conv);
} else if super::access::check_conversation_access(&self.db, &conv, user_id)
.await?
!= super::AccessLevel::Denied
{
visible.push(conv);
}
if visible.len() >= page_size as usize {
break;
}
}
return Ok(visible);
}
None => {
// Not a project member — only show own chats
query = query
.filter(ai_conversation::Column::ProjectId.eq(pid))
.filter(ai_conversation::Column::UserId.eq(user_id));
}
}
} else {
// Personal scope — only own chats without a project
query = query
.filter(ai_conversation::Column::UserId.eq(user_id))
.filter(ai_conversation::Column::ProjectId.is_null());
}
// Apply search filter if provided
if let Some(ref q) = search_query {
if !q.is_empty() {
query = query.filter(ai_conversation::Column::Title.contains(q));
}
}
let convs = query
.paginate(self.db.reader(), page_size)
.fetch_page(0)
.await?;
Ok(convs)
}
pub async fn update_conversation(
&self,
conversation_id: Uuid,
user_id: Uuid,
title: Option<String>,
model: Option<String>,
model_config: Option<serde_json::Value>,
status: Option<String>,
access_visibility: Option<String>,
can_ask: Option<String>,
model_uid: Option<Uuid>,
model_name: Option<String>,
) -> Result<(), AppError> {
let c = self
.find_conversation_creator(conversation_id, user_id)
.await?;
let mut active: ai_conversation::ActiveModel = c.into();
if let Some(t) = title {
active.title = Set(Some(t));
}
if let Some(m) = model {
active.model = Set(m);
}
if let Some(mc) = model_config {
active.model_config = Set(Some(mc));
}
if let Some(s) = status {
active.status = Set(s);
}
if let Some(av) = access_visibility {
active.access_visibility = Set(av);
}
if let Some(ca) = can_ask {
active.can_ask = Set(ca);
}
if let Some(mu) = model_uid {
active.model_uid = Set(Some(mu));
}
if let Some(mn) = model_name {
active.model_name = Set(Some(mn));
}
active.updated_at = Set(chrono::Utc::now());
active.update(self.db.writer()).await?;
Ok(())
}
pub async fn delete_conversation(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
self.find_conversation_creator(conversation_id, user_id)
.await?;
AiConversation::delete_by_id(conversation_id)
.exec(self.db.writer())
.await?;
Ok(())
}
}