gitdataai/libs/service/chat/conversation.rs

264 lines
9.6 KiB
Rust

use models::ai::{AiConversation, ai_conversation};
use models::projects::MemberRole;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use crate::error::AppError;
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 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,
) -> 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));
// Filter visible conversations based on role
// Owner sees all; Admin sees own + member-visible; Member sees only member-visible + own
if !matches!(r, MemberRole::Owner) {
// Not owner, so apply visibility filter:
// - Own conversations
// - OR access_visibility = "member" (for member) or "admin"/"member" (for admin)
// - OR hierarchical: admin sees member creator's chats
}
}
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());
}
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_owned(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_owned(conversation_id, user_id).await?;
AiConversation::delete_by_id(conversation_id)
.exec(self.db.writer())
.await?;
Ok(())
}
}