feat(room): AI typing indicator with 60s Redis TTL and WS replay

- Add sender_type field to TypingEvent (user/ai)
- Change Redis TTL from 10s to 60s for AI typing persistence
- Broadcast typing.start/stop with sender_type=ai when AI stream starts/ends
- Replay active AI typing events from Redis on new WS subscribe
- Fix ai.stream_chunk WS payload missing display_name and chunk_type
- Add initial thinking chunk on AI stream start for immediate indicator
This commit is contained in:
ZhenYi 2026-04-25 22:45:03 +08:00
parent 91bebba45e
commit 78eee672a4
4 changed files with 107 additions and 9 deletions

View File

@ -276,6 +276,8 @@ pub async fn ws_universal(
"content": chunk.content, "content": chunk.content,
"done": chunk.done, "done": chunk.done,
"error": chunk.error, "error": chunk.error,
"display_name": chunk.display_name,
"chunk_type": chunk.chunk_type,
}, },
}); });
if session.text(payload.to_string()).await.is_err() { if session.text(payload.to_string()).await.is_err() {
@ -292,6 +294,7 @@ pub async fn ws_universal(
"username": event.username, "username": event.username,
"avatar_url": event.avatar_url, "avatar_url": event.avatar_url,
"action": event.action, "action": event.action,
"sender_type": event.sender_type.as_deref().unwrap_or("user"),
}, },
}); });
if session.text(payload.to_string()).await.is_err() { if session.text(payload.to_string()).await.is_err() {

View File

@ -54,6 +54,9 @@ pub struct TypingEvent {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
/// "start" or "stop" /// "start" or "stop"
pub action: String, pub action: String,
/// Sender type: "user" or "ai". Defaults to "user" if absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub sender_type: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -634,24 +634,30 @@ impl RoomConnectionManager {
room_id: Uuid, room_id: Uuid,
) -> broadcast::Receiver<Arc<TypingEvent>> { ) -> broadcast::Receiver<Arc<TypingEvent>> {
let mut map: tokio::sync::RwLockWriteGuard<'_, std::collections::HashMap<Uuid, broadcast::Sender<Arc<TypingEvent>>>> = self.typing_inner.write().await; let mut map: tokio::sync::RwLockWriteGuard<'_, std::collections::HashMap<Uuid, broadcast::Sender<Arc<TypingEvent>>>> = self.typing_inner.write().await;
if let Some(tx) = map.get(&room_id) { let tx = map.entry(room_id).or_insert_with(|| {
return tx.subscribe(); let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
tx
});
// Replay active typing state from Redis to the new subscriber.
// This ensures newly connected WS clients see who is currently typing.
let active_events = self.get_active_typing_events(room_id).await;
for event in active_events {
let _ = tx.send(Arc::new(event));
} }
let (tx, rx) = broadcast::channel(BROADCAST_CAPACITY); tx.subscribe()
map.insert(room_id, tx);
rx
} }
/// Broadcast a typing event and persist it to Redis with 10s TTL. /// Broadcast a typing event and persist it to Redis with 60s TTL.
/// - "start": writes key with 10s expiry, broadcasts start event /// - "start": writes key with 60s expiry, broadcasts start event
/// - "stop": deletes key, broadcasts stop event /// - "stop": deletes key, broadcasts stop event
pub async fn broadcast_typing(&self, room_id: Uuid, event: TypingEvent) { pub async fn broadcast_typing(&self, room_id: Uuid, event: TypingEvent) {
let user_key = format!("typing:{}:{}", room_id, event.user_id); let user_key = format!("typing:{}:{}", room_id, event.user_id);
let action = event.action.clone(); let action = event.action.clone();
let username = event.username.clone(); let username = event.username.clone();
let avatar_url = event.avatar_url.clone(); let avatar_url = event.avatar_url.clone();
let sender_type = event.sender_type.clone().unwrap_or_else(|| "user".to_string());
// Write/delete Redis key for 10s expiry (non-blocking) // Write/delete Redis key for 60s expiry (non-blocking)
if let Ok(mut conn) = self.cache.conn().await { if let Ok(mut conn) = self.cache.conn().await {
let key = user_key; let key = user_key;
tokio::spawn(async move { tokio::spawn(async move {
@ -659,11 +665,12 @@ impl RoomConnectionManager {
let value = serde_json::json!({ let value = serde_json::json!({
"username": username, "username": username,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"sender_type": sender_type,
}) })
.to_string(); .to_string();
let _: Result<(), _> = redis::cmd("SETEX") let _: Result<(), _> = redis::cmd("SETEX")
.arg(&key) .arg(&key)
.arg(10i64) .arg(60i64)
.arg(&value) .arg(&value)
.query_async(&mut conn) .query_async(&mut conn)
.await; .await;
@ -679,6 +686,43 @@ impl RoomConnectionManager {
let _ = tx.send(event); let _ = tx.send(event);
} }
} }
/// Load all active typing entries for a room from Redis and return as TypingEvents.
/// Used to replay current typing state to newly connected WS clients.
pub async fn get_active_typing_events(&self, room_id: Uuid) -> Vec<TypingEvent> {
let pattern = format!("typing:{}:*", room_id);
if let Ok(mut conn) = self.cache.conn().await {
let keys: Vec<String> = match redis::cmd("KEYS").arg(&pattern).query_async(&mut conn).await {
Ok(k) => k,
Err(_) => return vec![],
};
if keys.is_empty() {
return vec![];
}
let mut results = Vec::new();
for key in keys {
let parts: Vec<&str> = key.split(':').collect();
let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok());
if let (Some(Ok(value)), Some(Ok(user_uuid)))) = (
redis::cmd("GET").arg(&key).query_async::<String>(&mut conn).await.ok(),
user_id,
) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&value) {
results.push(TypingEvent {
room_id,
user_id: user_uuid,
username: parsed.get("username").and_then(|v| v.as_str()).unwrap_or("").to_string(),
avatar_url: parsed.get("avatar_url").and_then(|v| v.as_str()).map(String::from),
action: "start".to_string(),
sender_type: parsed.get("sender_type").and_then(|v| v.as_str()).map(String::from),
});
}
}
}
return results;
}
vec![]
}
} }
fn parse_sender_type(s: &str) -> MessageSenderType { fn parse_sender_type(s: &str) -> MessageSenderType {

View File

@ -1030,6 +1030,19 @@ impl RoomService {
.register_stream_channel(streaming_msg_id) .register_stream_channel(streaming_msg_id)
.await; .await;
// Emit an initial "thinking" chunk immediately so the frontend shows the
// "AI is thinking..." indicator without waiting for the first real token.
let initial_event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
content: String::new(),
done: false,
error: None,
display_name: Some(request.model.name.clone()),
chunk_type: Some("thinking".to_string()),
};
self.room_manager.broadcast_stream_chunk(initial_event).await;
let room_manager = self.room_manager.clone(); let room_manager = self.room_manager.clone();
let db = self.db.clone(); let db = self.db.clone();
let room_id_inner = room_id; let room_id_inner = room_id;
@ -1046,6 +1059,8 @@ impl RoomService {
let room_manager = room_manager.clone(); let room_manager = room_manager.clone();
let db = db.clone(); let db = db.clone();
let model_id = model_id; let model_id = model_id;
// Fixed UUID to identify AI typing events across WS reconnections.
let ai_typing_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
// Clone before closure so closure captures clone, not the original. // Clone before closure so closure captures clone, not the original.
let ai_display_name_for_chunk = ai_display_name.clone(); let ai_display_name_for_chunk = ai_display_name.clone();
let ai_display_name_for_final = ai_display_name.clone(); let ai_display_name_for_final = ai_display_name.clone();
@ -1088,6 +1103,17 @@ impl RoomService {
let stream_callback: agent::chat::StreamCallback = Box::new(on_chunk); let stream_callback: agent::chat::StreamCallback = Box::new(on_chunk);
// Broadcast AI typing.start so WS clients (including reconnections) see the indicator.
let typing_start = queue::TypingEvent {
room_id: room_id_inner,
user_id: ai_typing_id,
username: ai_display_name.clone(),
avatar_url: None,
action: "start".to_string(),
sender_type: Some("ai".to_string()),
};
room_manager.broadcast_typing(room_id_inner, typing_start).await;
match chat_service.process_stream(request, stream_callback).await { match chat_service.process_stream(request, stream_callback).await {
Ok(full_content) => { Ok(full_content) => {
let envelope = RoomMessageEnvelope { let envelope = RoomMessageEnvelope {
@ -1142,6 +1168,17 @@ impl RoomService {
room_manager.broadcast(room_id_inner, msg_event).await; room_manager.broadcast(room_id_inner, msg_event).await;
room_manager.metrics.messages_sent.increment(1); room_manager.metrics.messages_sent.increment(1);
// Stop AI typing indicator now that the message is delivered.
let typing_stop = queue::TypingEvent {
room_id: room_id_inner,
user_id: ai_typing_id,
username: ai_display_name_for_final.clone(),
avatar_url: None,
action: "stop".to_string(),
sender_type: Some("ai".to_string()),
};
room_manager.broadcast_typing(room_id_inner, typing_stop).await;
let event = queue::ProjectRoomEvent { let event = queue::ProjectRoomEvent {
event_type: super::RoomEventType::NewMessage.as_str().into(), event_type: super::RoomEventType::NewMessage.as_str().into(),
project_id: project_id_inner, project_id: project_id_inner,
@ -1158,6 +1195,17 @@ impl RoomService {
} }
Err(e) => { Err(e) => {
tracing::error!(error = %e, "AI streaming failed"); tracing::error!(error = %e, "AI streaming failed");
// Stop AI typing indicator since the stream failed.
let typing_stop = queue::TypingEvent {
room_id: room_id_inner,
user_id: ai_typing_id,
username: ai_display_name.clone(),
avatar_url: None,
action: "stop".to_string(),
sender_type: Some("ai".to_string()),
};
room_manager.broadcast_typing(room_id_inner, typing_stop).await;
let event = RoomMessageStreamChunkEvent { let event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id, message_id: streaming_msg_id,
room_id: room_id_inner, room_id: room_id_inner,