use async_openai::Client; use async_openai::config::OpenAIConfig; use async_openai::types::chat::{ ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions, }; use futures::StreamExt; use models::projects::project_skill; use models::rooms::room_ai; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; use super::context::RoomMessageContext; use super::{AiRequest, AiStreamChunk, Mention, StreamCallback}; use crate::compact::{CompactConfig, CompactService}; use crate::embed::EmbedService; use crate::error::{AgentError, Result}; use crate::perception::{PerceptionService, SkillEntry, ToolCallEvent}; use crate::tool::{ToolCall, ToolContext, ToolExecutor}; /// Service for handling AI chat requests in rooms. pub struct ChatService { openai_client: Client, compact_service: Option, embed_service: Option, perception_service: PerceptionService, } impl ChatService { pub fn new(openai_client: Client) -> Self { Self { openai_client, compact_service: None, embed_service: None, perception_service: PerceptionService::default(), } } pub fn with_compact_service(mut self, compact_service: CompactService) -> Self { self.compact_service = Some(compact_service); self } pub fn with_embed_service(mut self, embed_service: EmbedService) -> Self { self.embed_service = Some(embed_service); self } pub fn with_perception_service(mut self, perception_service: PerceptionService) -> Self { self.perception_service = perception_service; self } #[allow(deprecated)] pub async fn process(&self, request: AiRequest) -> Result { let tools: Vec = request.tools.clone().unwrap_or_default(); let tools_enabled = !tools.is_empty(); let tool_choice = tools_enabled.then(|| { async_openai::types::chat::ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::Auto) }); let think = request.think; let max_tool_depth = request.max_tool_depth; let top_p = request.top_p; let frequency_penalty = request.frequency_penalty; let presence_penalty = request.presence_penalty; let temperature_f = request.temperature; let max_tokens_i = request.max_tokens; let mut messages = self.build_messages(&request).await?; let room_ai = room_ai::Entity::find() .filter(room_ai::Column::Room.eq(request.room.id)) .filter(room_ai::Column::Model.eq(request.model.id)) .one(&request.db) .await?; let model_name = request.model.name.clone(); let temperature = room_ai .as_ref() .and_then(|r| r.temperature.map(|v| v as f32)) .unwrap_or(temperature_f as f32); let max_tokens = room_ai .as_ref() .and_then(|r| r.max_tokens.map(|v| v as u32)) .unwrap_or(max_tokens_i as u32); let mut tool_depth = 0; loop { let req = CreateChatCompletionRequest { model: model_name.clone(), messages: messages.clone(), temperature: Some(temperature), max_completion_tokens: Some(max_tokens), top_p: Some(top_p as f32), frequency_penalty: Some(frequency_penalty as f32), presence_penalty: Some(presence_penalty as f32), stream: Some(false), reasoning_effort: Some(if think { ReasoningEffort::High } else { ReasoningEffort::None }), tools: if tools_enabled { Some( tools .iter() .map(|t| ChatCompletionTools::Function(t.clone())) .collect(), ) } else { None }, tool_choice: tool_choice.clone(), ..Default::default() }; let response: CreateChatCompletionResponse = self .openai_client .chat() .create(req) .await .map_err(|e| AgentError::OpenAi(e.to_string()))?; let choice = response .choices .into_iter() .next() .ok_or_else(|| AgentError::Internal("no choice in response".into()))?; if tools_enabled { if let Some(ref tool_calls) = choice.message.tool_calls { if !tool_calls.is_empty() { messages.push(ChatCompletionRequestMessage::Assistant( ChatCompletionRequestAssistantMessage { content: choice .message .content .clone() .map(ChatCompletionRequestAssistantMessageContent::Text), name: None, refusal: None, audio: None, tool_calls: Some(tool_calls.clone()), function_call: None, }, )); let calls: Vec = tool_calls .iter() .filter_map(|tc| { if let ChatCompletionMessageToolCalls::Function( async_openai::types::chat::ChatCompletionMessageToolCall { id, function, }, ) = tc { Some(ToolCall { id: id.clone(), name: function.name.clone(), arguments: function.arguments.clone(), }) } else { None } }) .collect(); if !calls.is_empty() { let tool_messages = self.execute_tool_calls(calls, &request).await?; messages.extend(tool_messages); tool_depth += 1; if tool_depth >= max_tool_depth { return Ok(String::new()); } continue; } } } } let text = choice.message.content.unwrap_or_default(); return Ok(text); } } #[allow(deprecated)] pub async fn process_stream(&self, request: AiRequest, on_chunk: StreamCallback) -> Result<()> { let tools: Vec = request.tools.clone().unwrap_or_default(); let tools_enabled = !tools.is_empty(); let tool_choice = tools_enabled.then(|| { async_openai::types::chat::ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::Auto) }); let think = request.think; let max_tool_depth = request.max_tool_depth; let top_p = request.top_p; let frequency_penalty = request.frequency_penalty; let presence_penalty = request.presence_penalty; let temperature_f = request.temperature; let max_tokens_i = request.max_tokens; let mut messages = self.build_messages(&request).await?; let room_ai = room_ai::Entity::find() .filter(room_ai::Column::Room.eq(request.room.id)) .filter(room_ai::Column::Model.eq(request.model.id)) .one(&request.db) .await?; let model_name = request.model.name.clone(); let temperature = room_ai .as_ref() .and_then(|r| r.temperature.map(|v| v as f32)) .unwrap_or(temperature_f as f32); let max_tokens = room_ai .as_ref() .and_then(|r| r.max_tokens.map(|v| v as u32)) .unwrap_or(max_tokens_i as u32); let mut tool_depth = 0; loop { let req = CreateChatCompletionRequest { model: model_name.clone(), messages: messages.clone(), temperature: Some(temperature), max_completion_tokens: Some(max_tokens), top_p: Some(top_p as f32), frequency_penalty: Some(frequency_penalty as f32), presence_penalty: Some(presence_penalty as f32), stream: Some(true), reasoning_effort: Some(if think { ReasoningEffort::High } else { ReasoningEffort::None }), tools: if tools_enabled { Some( tools .iter() .map(|t| ChatCompletionTools::Function(t.clone())) .collect(), ) } else { None }, tool_choice: tool_choice.clone(), ..Default::default() }; let mut stream = self .openai_client .chat() .create_stream(req) .await .map_err(|e| AgentError::OpenAi(e.to_string()))?; let mut text_accumulated = String::new(); let mut tool_call_chunks: Vec = Vec::new(); let mut finish_reason: Option = None; while let Some(chunk_result) = stream.next().await { let chunk: CreateChatCompletionStreamResponse = chunk_result.map_err(|e| AgentError::OpenAi(e.to_string()))?; let choice = match chunk.choices.first() { Some(c) => c, None => continue, }; // Track finish reason if let Some(ref fr) = choice.finish_reason { finish_reason = Some(fr.clone()); } // Text delta if let Some(content) = &choice.delta.content { text_accumulated.push_str(content); on_chunk(AiStreamChunk { content: text_accumulated.clone(), done: false, }) .await; } // Tool call deltas if let Some(ref tool_chunks) = choice.delta.tool_calls { for tc in tool_chunks { let idx = tc.index as usize; if tool_call_chunks.len() <= idx { tool_call_chunks.resize(idx + 1, ToolCallChunkAccum::default()); } if let Some(ref id) = tc.id { tool_call_chunks[idx].id = Some(id.clone()); } if let Some(ref fc) = tc.function { if let Some(ref name) = fc.name { tool_call_chunks[idx].name.push_str(name); } if let Some(ref args) = fc.arguments { tool_call_chunks[idx].arguments.push_str(args); } } } } } let has_tool_calls = matches!( finish_reason, Some(FinishReason::ToolCalls) | Some(FinishReason::FunctionCall) ); if has_tool_calls && tools_enabled { // Send final text chunk on_chunk(AiStreamChunk { content: text_accumulated.clone(), done: true, }) .await; // Build ToolCall list from accumulated chunks let tool_calls: Vec<_> = tool_call_chunks .into_iter() .filter(|c| !c.name.is_empty()) .map(|c| ToolCall { id: c.id.unwrap_or_else(|| Uuid::new_v4().to_string()), name: c.name, arguments: c.arguments, }) .collect(); if !tool_calls.is_empty() { // Append assistant message with tool calls to history messages.push(ChatCompletionRequestMessage::Assistant( ChatCompletionRequestAssistantMessage { content: Some( ChatCompletionRequestAssistantMessageContent::Text( text_accumulated, ), ), name: None, refusal: None, audio: None, tool_calls: Some( tool_calls .iter() .map(|tc| { ChatCompletionMessageToolCalls::Function( async_openai::types::chat::ChatCompletionMessageToolCall { id: tc.id.clone(), function: async_openai::types::chat::FunctionCall { name: tc.name.clone(), arguments: tc.arguments.clone(), }, }, ) }) .collect(), ), function_call: None, }, )); let tool_messages = self.execute_tool_calls(tool_calls, &request).await?; messages.extend(tool_messages); tool_depth += 1; if tool_depth >= max_tool_depth { return Ok(()); } continue; } } on_chunk(AiStreamChunk { content: text_accumulated, done: true, }) .await; return Ok(()); } } /// Executes a batch of tool calls and returns the tool result messages. async fn execute_tool_calls( &self, calls: Vec, request: &AiRequest, ) -> Result> { let mut ctx = ToolContext::new( request.db.clone(), request.cache.clone(), request.room.id, Some(request.sender.uid), ) .with_project(request.project.id); let executor = ToolExecutor::new(); let results = executor .execute_batch(calls, &mut ctx) .await .map_err(|e| AgentError::Internal(e.to_string()))?; Ok(ToolExecutor::to_tool_messages(&results)) } async fn build_messages( &self, request: &AiRequest, ) -> Result> { let mut messages = Vec::new(); let mut processed_history = Vec::new(); if let Some(compact_service) = &self.compact_service { // Auto-compact: only compresses when token count exceeds threshold let config = CompactConfig::default(); match compact_service .compact_room_auto(request.room.id, Some(request.user_names.clone()), config) .await { Ok(compact_summary) => { if !compact_summary.summary.is_empty() { messages.push(ChatCompletionRequestMessage::System( ChatCompletionRequestSystemMessage { content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text( format!("Conversation summary:\n{}", compact_summary.summary), ), ..Default::default() }, )); } processed_history = compact_summary.retained; } Err(e) => { let _ = e; } } } if !processed_history.is_empty() { for msg_summary in processed_history { let ctx = RoomMessageContext::from(msg_summary); messages.push(ctx.to_message()); } } else { for msg in &request.history { let ctx = RoomMessageContext::from_model_with_names(msg, &request.user_names); messages.push(ctx.to_message()); } } if let Some(embed_service) = &self.embed_service { for mention in &request.mention { match mention { Mention::Repo(repo) => { let query = format!( "{} {}", repo.repo_name, repo.description.as_deref().unwrap_or_default() ); match embed_service.search_issues(&query, 5).await { Ok(issues) if !issues.is_empty() => { let context = format!( "Related issues:\n{}", issues .iter() .map(|i| format!("- {}", i.payload.text)) .collect::>() .join("\n") ); messages.push(ChatCompletionRequestMessage::System( ChatCompletionRequestSystemMessage { content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text( context, ), ..Default::default() }, )); } Err(e) => { let _ = e; } _ => {} } match embed_service.search_repos(&query, 3).await { Ok(repos) if !repos.is_empty() => { let context = format!( "Related repositories:\n{}", repos .iter() .map(|r| format!("- {}", r.payload.text)) .collect::>() .join("\n") ); messages.push(ChatCompletionRequestMessage::System( ChatCompletionRequestSystemMessage { content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text( context, ), ..Default::default() }, )); } Err(e) => { let _ = e; } _ => {} } } Mention::User(user) => { let mut profile_parts = vec![format!("Username: {}", user.username)]; if let Some(ref display_name) = user.display_name { profile_parts.push(format!("Display name: {}", display_name)); } if let Some(ref org) = user.organization { profile_parts.push(format!("Organization: {}", org)); } if let Some(ref website) = user.website_url { profile_parts.push(format!("Website: {}", website)); } messages.push(ChatCompletionRequestMessage::System( ChatCompletionRequestSystemMessage { content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text( format!("Mentioned user profile:\n{}", profile_parts.join("\n")), ), ..Default::default() }, )); } } } } // Inject relevant skills via the perception system (auto + active + passive). let skill_contexts = self.build_skill_context(request).await; for ctx in skill_contexts { messages.push(ctx.to_system_message() as ChatCompletionRequestMessage); } // Inject relevant past conversation memories via vector similarity. let memories = self.build_memory_context(request).await; for mem in memories { messages.push(mem.to_system_message()); } messages.push(ChatCompletionRequestMessage::User( ChatCompletionRequestUserMessage { content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text( request.input.clone(), ), ..Default::default() }, )); Ok(messages) } /// Fetch enabled skills for the current project and run them through the /// perception system (auto + active + passive) to inject relevant context. async fn build_skill_context( &self, request: &AiRequest, ) -> Vec { // Fetch enabled skills for this project. let skills: Vec = match project_skill::Entity::find() .filter(project_skill::Column::ProjectUuid.eq(request.project.id)) .filter(project_skill::Column::Enabled.eq(true)) .all(&request.db) .await { Ok(models) => models .into_iter() .map(|s| SkillEntry { slug: s.slug, name: s.name, description: s.description, content: s.content, }) .collect(), Err(_) => return Vec::new(), }; if skills.is_empty() { return Vec::new(); } // Build history text for auto-awareness scoring. let history_texts: Vec = request .history .iter() .rev() .take(10) .map(|msg| msg.content.clone()) .collect(); // Active + passive + auto perception (keyword-based). let tool_events: Vec = Vec::new(); // Tool calls tracked in loop via process() let keyword_skills = self .perception_service .inject_skills(&request.input, &history_texts, &tool_events, &skills) .await; // Vector-aware active perception: semantic search for skills via Qdrant. let mut vector_skills = Vec::new(); if let Some(embed_service) = &self.embed_service { let awareness = crate::perception::VectorActiveAwareness::default(); vector_skills = awareness .detect(embed_service, &request.input, &request.project.id.to_string()) .await; } // Merge: deduplicate by label, preferring vector results (higher signal). let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); for ctx in vector_skills { if seen.insert(ctx.label.clone()) { result.push(ctx); } } for ctx in keyword_skills { if seen.insert(ctx.label.clone()) { result.push(ctx); } } result } /// Inject relevant past conversation memories via vector similarity search. async fn build_memory_context( &self, request: &AiRequest, ) -> Vec { let embed_service = match &self.embed_service { Some(s) => s, None => return Vec::new(), }; // Search memories by current input semantic similarity. let awareness = crate::perception::VectorPassiveAwareness::default(); awareness .detect(embed_service, &request.input, &request.room.id.to_string()) .await } } #[derive(Clone, Debug, Default)] struct ToolCallChunkAccum { id: Option, name: String, arguments: String, }