656 lines
26 KiB
Rust
656 lines
26 KiB
Rust
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<OpenAIConfig>,
|
|
compact_service: Option<CompactService>,
|
|
embed_service: Option<EmbedService>,
|
|
perception_service: PerceptionService,
|
|
}
|
|
|
|
impl ChatService {
|
|
pub fn new(openai_client: Client<OpenAIConfig>) -> 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<String> {
|
|
let tools: Vec<ChatCompletionTool> = 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<ToolCall> = 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<ChatCompletionTool> = 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<ToolCallChunkAccum> = Vec::new();
|
|
let mut finish_reason: Option<FinishReason> = 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<ToolCall>,
|
|
request: &AiRequest,
|
|
) -> Result<Vec<ChatCompletionRequestMessage>> {
|
|
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<Vec<ChatCompletionRequestMessage>> {
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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<crate::perception::SkillContext> {
|
|
// Fetch enabled skills for this project.
|
|
let skills: Vec<SkillEntry> = 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<String> = request
|
|
.history
|
|
.iter()
|
|
.rev()
|
|
.take(10)
|
|
.map(|msg| msg.content.clone())
|
|
.collect();
|
|
|
|
// Active + passive + auto perception (keyword-based).
|
|
let tool_events: Vec<ToolCallEvent> = 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<crate::perception::vector::MemoryContext> {
|
|
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<String>,
|
|
name: String,
|
|
arguments: String,
|
|
}
|