refactor(service): update service layer, TOTP, and AI streaming

Refine room AI streaming logic, update TOTP auth error handling,
and adjust user 2FA migration order. Remove unused service exports.
This commit is contained in:
ZhenYi 2026-05-11 17:05:59 +08:00
parent 0f800da74d
commit de85417053
5 changed files with 139 additions and 42 deletions

View File

@ -1,9 +1,10 @@
CREATE TABLE IF NOT EXISTS user_2fa ( CREATE TABLE IF NOT EXISTS user_2fa
"user" UUID PRIMARY KEY, (
method VARCHAR(255) NOT NULL, "user" UUID PRIMARY KEY,
secret VARCHAR(255), method VARCHAR(255) NOT NULL,
backup_codes JSONB NOT NULL, secret VARCHAR(255),
is_enabled BOOLEAN NOT NULL DEFAULT false, backup_codes JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
); );

View File

@ -1,5 +1,6 @@
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::Ordering;
use chrono::Utc; use chrono::Utc;
use db::cache::AppCache; use db::cache::AppCache;
@ -7,11 +8,12 @@ use db::database::AppDatabase;
use models::rooms::{room_ai, room_message}; use models::rooms::{room_ai, room_message};
use queue::{MessageProducer, ProjectRoomEvent, RoomMessageEnvelope}; use queue::{MessageProducer, ProjectRoomEvent, RoomMessageEnvelope};
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set}; use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set};
use tokio::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
use super::sequence::next_room_message_seq_internal; use super::sequence::next_room_message_seq_internal;
use crate::connection::RoomConnectionManager; use crate::connection::RoomConnectionManager;
use agent::chat::{normalize_thinking_content, AiRequest, ChatService}; use agent::chat::{normalize_thinking_content, AiChunkType, AiRequest, ChatService};
pub async fn process_message_ai_streaming( pub async fn process_message_ai_streaming(
chat_service: Arc<ChatService>, chat_service: Arc<ChatService>,
@ -70,6 +72,10 @@ pub async fn process_message_ai_streaming(
let chunk_count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let chunk_count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let queue_for_chunk = queue.clone(); let queue_for_chunk = queue.clone();
// Answer/thinking buffering for smooth SSE — reduce per-token event overhead.
let answer_buf = Arc::new(Mutex::new(String::new()));
let thinking_buf = Arc::new(Mutex::new(String::new()));
let on_chunk = move |chunk: agent::chat::AiStreamChunk| { let on_chunk = move |chunk: agent::chat::AiStreamChunk| {
Box::pin({ Box::pin({
let queue = queue_for_chunk.clone(); let queue = queue_for_chunk.clone();
@ -78,35 +84,129 @@ pub async fn process_message_ai_streaming(
let chunk_count = chunk_count.clone(); let chunk_count = chunk_count.clone();
let ai_display_name_for_chunk = ai_display_name_for_chunk.clone(); let ai_display_name_for_chunk = ai_display_name_for_chunk.clone();
let cancel = cancel.clone(); let cancel = cancel.clone();
let answer_buf = answer_buf.clone();
let thinking_buf = thinking_buf.clone();
async move { async move {
if cancel.load(std::sync::atomic::Ordering::Acquire) { if cancel.load(std::sync::atomic::Ordering::Acquire) {
// Stream was explicitly cancelled via cancel_ai_stream — drop this chunk
return; return;
} }
let chunk_type_str = match chunk.chunk_type {
agent::chat::AiChunkType::Thinking => "thinking", match chunk.chunk_type {
agent::chat::AiChunkType::Answer => "answer", AiChunkType::Thinking => {
agent::chat::AiChunkType::ToolCall => "tool_call", let mut buf = thinking_buf.lock().await;
agent::chat::AiChunkType::ToolResult => "tool_result", buf.push_str(&chunk.content);
}; // Only flush when we hit a newline boundary (natural paragraph break)
let content = match chunk.chunk_type { if chunk.content.contains('\n') || buf.len() > 500 {
agent::chat::AiChunkType::Thinking => normalize_thinking_content(&chunk.content), let content = normalize_thinking_content(&buf);
_ => chunk.content, let flush = !buf.is_empty();
}; buf.clear();
let seq = chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if flush && !cancel.clone().load(Ordering::Acquire) {
let event = RoomMessageStreamChunkEvent { let seq = chunk_count.fetch_add(1, Ordering::Relaxed);
message_id: streaming_msg_id, let event = queue::RoomMessageStreamChunkEvent {
room_id, message_id: streaming_msg_id,
seq, room_id,
content, seq,
done: chunk.done, content,
error: None, done: false,
display_name: Some(ai_display_name_for_chunk), error: None,
chunk_type: Some(chunk_type_str.to_string()), display_name: Some(ai_display_name_for_chunk.clone()),
}; chunk_type: Some("thinking".to_string()),
// Ignore send error (let _) so the task continues even if consumer is gone. };
// This ensures the AI continues to generate and the final result is persisted to DB. let _ = queue.publish_stream_chunk(&event).await;
let _ = queue.publish_stream_chunk(&event).await; }
}
}
AiChunkType::Answer => {
let mut buf = answer_buf.lock().await;
buf.push_str(&chunk.content);
// Flush on natural boundaries: newlines, or when content buffer grows large.
// Small tokens (< 6 chars without newline) accumulate until boundary.
let should_flush = chunk.content.contains('\n')
|| buf.len() >= 60
|| chunk.done;
if should_flush {
let content = std::mem::take(&mut *buf);
buf.clear();
if !cancel.load(Ordering::Acquire) {
let seq = chunk_count.fetch_add(1, Ordering::Relaxed);
let event = queue::RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
seq,
content,
done: chunk.done,
error: None,
display_name: Some(ai_display_name_for_chunk.clone()),
chunk_type: Some("answer".to_string()),
};
let _ = queue.publish_stream_chunk(&event).await;
}
}
}
AiChunkType::ToolCall | AiChunkType::ToolResult => {
// Tool events are critical — flush buffers first, then emit immediately.
// For ToolCall: flush any accumulated content before showing the call.
let answer_content = {
let mut buf = answer_buf.lock().await;
let content = std::mem::take(&mut *buf);
buf.clear();
content
};
let thinking_content = {
let mut buf = thinking_buf.lock().await;
let content = normalize_thinking_content(&std::mem::take(&mut *buf));
buf.clear();
content
};
if !cancel.load(Ordering::Acquire) {
let mut seq = chunk_count.fetch_add(1, Ordering::Relaxed);
if !answer_content.is_empty() {
let event = queue::RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
seq,
content: answer_content,
done: false,
error: None,
display_name: Some(ai_display_name_for_chunk.clone()),
chunk_type: Some("answer".to_string()),
};
let _ = queue.publish_stream_chunk(&event).await;
seq = chunk_count.fetch_add(1, Ordering::Relaxed);
}
if !thinking_content.is_empty() {
let event = queue::RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
seq,
content: thinking_content,
done: false,
error: None,
display_name: Some(ai_display_name_for_chunk.clone()),
chunk_type: Some("thinking".to_string()),
};
let _ = queue.publish_stream_chunk(&event).await;
seq = chunk_count.fetch_add(1, Ordering::Relaxed);
}
let chunk_type_str = match chunk.chunk_type {
AiChunkType::ToolCall => "tool_call",
_ => "tool_result",
};
let content = chunk.content;
let event = queue::RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
seq,
content,
done: false,
error: None,
display_name: Some(ai_display_name_for_chunk.clone()),
chunk_type: Some(chunk_type_str.to_string()),
};
let _ = queue.publish_stream_chunk(&event).await;
}
}
}
} }
}) as Pin<Box<dyn std::future::Future<Output = ()> + Send>> }) as Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}; };

View File

@ -363,7 +363,7 @@ impl AppService {
fn hash_backup_code(code: &str) -> String { fn hash_backup_code(code: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(code.as_bytes()); hasher.update(code.as_bytes());
format!("{:x}", hasher.finalize()) hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::<String>()
} }
fn hash_backup_codes(codes: &[String]) -> Vec<String> { fn hash_backup_codes(codes: &[String]) -> Vec<String> {
@ -387,7 +387,7 @@ impl AppService {
} }
fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> { fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> {
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac, KeyInit};
use sha1::Sha1; use sha1::Sha1;
let secret_bytes = self.decode_base32(secret)?; let secret_bytes = self.decode_base32(secret)?;

View File

@ -2,9 +2,8 @@ use std::sync::Arc;
use ::agent::chat::ChatService; use ::agent::chat::ChatService;
use ::agent::client::AiClientConfig; use ::agent::client::AiClientConfig;
use ::agent::task::service::TaskService;
use ::agent::tool::ToolRegistry; use ::agent::tool::ToolRegistry;
use ::agent::{new_embed_client, EmbedService}; use ::agent::{new_embed_client, EmbedService, TaskService};
use avatar::AppAvatar; use avatar::AppAvatar;
use config::AppConfig; use config::AppConfig;
use db::cache::AppCache; use db::cache::AppCache;
@ -43,9 +42,6 @@ pub struct AppService {
} }
impl AppService { impl AppService {
/// Send a Web Push notification to a specific user.
/// Reads the user's push subscription from `user_notification` table.
/// Non-blocking: failures are logged but don't affect the caller.
pub fn send_push_to_user(&self, user_id: uuid::Uuid, payload: PushPayload) { pub fn send_push_to_user(&self, user_id: uuid::Uuid, payload: PushPayload) {
push_helper::spawn_push_notification(self.push.clone(), self.db.clone(), user_id, payload); push_helper::spawn_push_notification(self.push.clone(), self.db.clone(), user_id, payload);
} }

View File

@ -1,4 +1,4 @@
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac, KeyInit};
use sha2::Sha256; use sha2::Sha256;
use std::time::Duration; use std::time::Duration;