feat(room): inject text messages into Qdrant for vector search

- message.rs: after creating a text message, spawn async task to
  embed and upsert into Qdrant collection "room:{project}:{room_id}"
- service.rs: RoomService now takes optional EmbedService, embed on
  every message creation
This commit is contained in:
ZhenYi 2026-04-25 20:09:16 +08:00
parent 215846b1db
commit 01285ca9ce
3 changed files with 74 additions and 34 deletions

View File

@ -40,7 +40,6 @@ utoipa = { workspace = true, features = ["uuid", "chrono"] }
metrics = "0.22"
regex-lite = "0.1.6"
redis = { workspace = true, features = ["tokio-comp", "connection-manager"] }
async-openai = { workspace = true }
hostname = "0.4"
dashmap = "7.0.0-rc2"
lru = "0.12.0"

View File

@ -306,6 +306,38 @@ impl RoomService {
}
}
// Embed user messages into Qdrant for vector memory search (non-blocking)
if is_text_message {
let embed_service = self.embed_service.clone();
let embed_content = content.clone();
let embed_room_id = room_id;
let embed_message_id = id;
let embed_user_id = user_id;
let embed_db = self.db.clone();
let embed_project_id = project_id;
tokio::spawn(async move {
if let Some(embed) = embed_service {
// Look up project name for the Qdrant collection namespace
let project_name = match models::projects::project::Entity::find_by_id(embed_project_id)
.one(&embed_db)
.await
{
Ok(Some(p)) => p.display_name,
_ => return,
};
let _ = embed
.embed_memory(
&embed_message_id.to_string(),
&embed_content,
&project_name,
&embed_room_id.to_string(),
Some(&embed_user_id.to_string()),
)
.await;
}
});
}
Ok(super::RoomMessageResponse {
id,
seq,

View File

@ -19,6 +19,7 @@ use crate::connection::{
};
use crate::error::RoomError;
use agent::chat::{AiRequest, ChatService, Mention};
use agent::embed::EmbedService;
use agent::react::ReactStep;
use agent::TaskService;
use models::agent_task::AgentType;
@ -61,6 +62,7 @@ pub struct RoomService {
pub redis_url: String,
pub chat_service: Option<Arc<ChatService>>,
pub task_service: Option<Arc<TaskService>>,
pub embed_service: Option<Arc<EmbedService>>,
pub push_fn: Option<PushNotificationFn>,
worker_semaphore: Arc<tokio::sync::Semaphore>,
dedup_cache: DedupCache,
@ -78,6 +80,7 @@ impl RoomService {
task_service: Option<Arc<TaskService>>,
max_concurrent_workers: Option<usize>,
push_fn: Option<PushNotificationFn>,
embed_service: Option<Arc<EmbedService>>,
) -> Self {
let dedup_cache: DedupCache =
Arc::new(DashMap::with_capacity_and_hasher(10000, Default::default()));
@ -90,6 +93,7 @@ impl RoomService {
redis_url,
chat_service,
task_service,
embed_service,
worker_semaphore: Arc::new(tokio::sync::Semaphore::new(
max_concurrent_workers.unwrap_or(DEFAULT_MAX_CONCURRENT_WORKERS),
)),
@ -889,7 +893,25 @@ impl RoomService {
.await?
.ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?;
let model = models::agents::model::Entity::find_by_id(ai_config.model)
// Parse @[ai:uuid:label] from content to allow per-mention model routing.
// If no mention is found, use the room's default model.
let mentioned_model_id = {
let mut found = None;
for cap in MENTION_BRACKET_RE.captures_iter(&content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "ai" {
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
found = Some(uuid);
break;
}
}
}
}
found
};
let model_id = mentioned_model_id.unwrap_or(ai_config.model);
let model = models::agents::model::Entity::find_by_id(model_id)
.one(&self.db)
.await?
.ok_or_else(|| RoomError::NotFound("AI model not found".to_string()))?;
@ -942,7 +964,7 @@ impl RoomService {
request,
room_id,
room.project,
ai_config.model,
model_id,
lock_guard,
)
.await;
@ -952,7 +974,7 @@ impl RoomService {
request,
room_id,
room.project,
ai_config.model,
model_id,
lock_guard,
)
.await;
@ -963,7 +985,7 @@ impl RoomService {
request,
room_id,
room.project,
ai_config.model,
model_id,
lock_guard,
)
.await;
@ -973,7 +995,7 @@ impl RoomService {
request,
room_id,
room.project,
ai_config.model,
model_id,
lock_guard,
)
.await;
@ -1002,7 +1024,8 @@ impl RoomService {
}
};
let stream_rx = self
// Register stream channel for real-time WebSocket broadcasting of chunks
let _ = self
.room_manager
.register_stream_channel(streaming_msg_id)
.await;
@ -1066,20 +1089,7 @@ impl RoomService {
let stream_callback: agent::chat::StreamCallback = Box::new(on_chunk);
match chat_service.process_stream(request, stream_callback).await {
Ok(()) => {
let full_content = {
let mut rx = stream_rx;
let mut content = String::new();
while let Ok(chunk_event) = rx.recv().await {
if chunk_event.done {
content = chunk_event.content.clone();
break;
}
content = chunk_event.content.clone();
}
content
};
Ok(full_content) => {
let envelope = RoomMessageEnvelope {
id: streaming_msg_id,
dedup_key: Some(format!("{}:{}", room_id_inner, streaming_msg_id)),
@ -1396,13 +1406,21 @@ impl RoomService {
step_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
let done = is_answer;
// Update buffers BEFORE spawning — must be synchronous so the main
// thread sees the updates immediately after `process_react` returns.
{
let mut rb = reasoning_buffer.lock().unwrap();
rb.push_str(&content);
rb.push('\n');
}
if is_answer {
let mut ab = answer_buffer.lock().unwrap();
ab.push_str(&content);
}
let done = is_answer;
let ai_name = ai_display_name_for_step.clone();
let reasoning_buf = reasoning_buffer.clone();
let answer_buf = answer_buffer.clone();
tokio::spawn(async move {
// Always broadcast every step as a stream chunk
let event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
@ -1417,15 +1435,6 @@ impl RoomService {
}),
};
room_manager.broadcast_stream_chunk(event).await;
// Collect all steps into reasoning_buffer; Answer goes to answer_buffer
let mut rb = reasoning_buf.lock().unwrap();
rb.push_str(&content);
rb.push('\n');
drop(rb);
if is_answer {
answer_buf.lock().unwrap().push_str(&content);
}
});
}
};