fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -281,6 +281,39 @@ pub async fn delete_provider_key(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Key 使用窗口统计
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageStats {
|
||||
pub key_id: String,
|
||||
pub window_minute: String,
|
||||
pub request_count: i32,
|
||||
pub token_count: i64,
|
||||
}
|
||||
|
||||
/// 查询指定 Key 的最近使用窗口统计
|
||||
pub async fn get_key_usage_stats(
|
||||
db: &PgPool,
|
||||
key_id: &str,
|
||||
limit: i64,
|
||||
) -> SaasResult<Vec<KeyUsageStats>> {
|
||||
let limit = limit.min(60).max(1);
|
||||
let rows: Vec<(String, String, i32, i64)> = sqlx::query_as(
|
||||
"SELECT key_id, window_minute, request_count, token_count \
|
||||
FROM key_usage_window \
|
||||
WHERE key_id = $1 \
|
||||
ORDER BY window_minute DESC \
|
||||
LIMIT $2"
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(limit)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(key_id, window_minute, request_count, token_count)| {
|
||||
KeyUsageStats { key_id, window_minute, request_count, token_count }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// 解析冷却剩余时间(秒)
|
||||
fn parse_cooldown_remaining(cooldown_until: &str, now: &str) -> i64 {
|
||||
let cooldown = chrono::DateTime::parse_from_rfc3339(cooldown_until);
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::models::RelayTaskRow;
|
||||
use super::types::*;
|
||||
|
||||
// ============ StreamBridge 背压常量 ============
|
||||
|
||||
/// 上游无数据时,发送 SSE 心跳注释行的间隔
|
||||
const STREAMBRIDGE_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 上游无数据时,丢弃连接的超时阈值
|
||||
const STREAMBRIDGE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 流结束后延迟清理的时间窗口
|
||||
const STREAMBRIDGE_CLEANUP_DELAY: Duration = Duration::from_secs(60);
|
||||
|
||||
/// 判断 HTTP 状态码是否为可重试的瞬态错误 (5xx + 429)
|
||||
fn is_retryable_status(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
@@ -33,15 +45,24 @@ pub async fn create_relay_task(
|
||||
let request_hash = hash_request(request_body);
|
||||
let max_attempts = max_attempts.max(1).min(5);
|
||||
|
||||
sqlx::query(
|
||||
// INSERT ... RETURNING 合并两次 DB 往返为一次
|
||||
let row: RelayTaskRow = sqlx::query_as(
|
||||
"INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9)"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9)
|
||||
RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at"
|
||||
)
|
||||
.bind(&id).bind(account_id).bind(provider_id).bind(model_id)
|
||||
.bind(&request_hash).bind(request_body).bind(priority).bind(max_attempts as i64).bind(&now)
|
||||
.execute(db).await?;
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
get_relay_task(db, &id).await
|
||||
Ok(RelayTaskInfo {
|
||||
id: row.id, account_id: row.account_id, provider_id: row.provider_id, model_id: row.model_id,
|
||||
status: row.status, priority: row.priority, attempt_count: row.attempt_count,
|
||||
max_attempts: row.max_attempts, input_tokens: row.input_tokens, output_tokens: row.output_tokens,
|
||||
error_message: row.error_message, queued_at: row.queued_at, started_at: row.started_at,
|
||||
completed_at: row.completed_at, created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_relay_task(db: &PgPool, task_id: &str) -> SaasResult<RelayTaskInfo> {
|
||||
@@ -295,9 +316,9 @@ pub async fn execute_relay(
|
||||
}
|
||||
});
|
||||
|
||||
// Convert mpsc::Receiver into a Body stream
|
||||
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
let body = axum::body::Body::from_stream(body_stream);
|
||||
// Build StreamBridge: wraps the bounded receiver with heartbeat,
|
||||
// timeout, and delayed cleanup (DeerFlow-inspired backpressure).
|
||||
let body = build_stream_bridge(rx, task_id.to_string());
|
||||
|
||||
// SSE 流结束后异步记录 usage + Key 使用量
|
||||
// 使用全局 Arc<Semaphore> 限制并发 spawned tasks,防止高并发时耗尽连接池
|
||||
@@ -335,6 +356,14 @@ pub async fn execute_relay(
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(5), db_op).await.is_err() {
|
||||
tracing::warn!("SSE usage recording timed out for task {}", task_id_clone);
|
||||
}
|
||||
|
||||
// StreamBridge 延迟清理:流结束 60s 后释放残留资源
|
||||
// (主要是 Arc<SseUsageCapture> 等,通过 drop(_permit) 归还信号量)
|
||||
tokio::time::sleep(STREAMBRIDGE_CLEANUP_DELAY).await;
|
||||
tracing::debug!(
|
||||
"[StreamBridge] Cleanup delay elapsed for task {}",
|
||||
task_id_clone
|
||||
);
|
||||
});
|
||||
|
||||
return Ok(RelayResponse::Sse(body));
|
||||
@@ -346,7 +375,9 @@ pub async fn execute_relay(
|
||||
// 记录 Key 使用量
|
||||
let _ = super::key_pool::record_key_usage(
|
||||
db, &key_id, Some(input_tokens + output_tokens),
|
||||
).await;
|
||||
).await.map_err(|e| {
|
||||
tracing::warn!("[Relay] Failed to record key usage for billing: {}", e);
|
||||
});
|
||||
return Ok(RelayResponse::Json(body));
|
||||
}
|
||||
}
|
||||
@@ -423,6 +454,98 @@ pub enum RelayResponse {
|
||||
Sse(axum::body::Body),
|
||||
}
|
||||
|
||||
// ============ StreamBridge ============
|
||||
|
||||
/// 构建 StreamBridge:将 mpsc::Receiver 包装为带心跳、超时的 axum Body。
|
||||
///
|
||||
/// 借鉴 DeerFlow StreamBridge 背压机制:
|
||||
/// - 15s 心跳:上游长时间无输出时,发送 SSE 注释行 `: heartbeat\n\n` 保持连接活跃
|
||||
/// - 30s 超时:上游连续 30s 无真实数据时,发送超时事件并关闭流
|
||||
/// - 60s 延迟清理:由调用方的 spawned task 在流结束后延迟释放资源
|
||||
fn build_stream_bridge(
|
||||
mut rx: tokio::sync::mpsc::Receiver<Result<bytes::Bytes, std::io::Error>>,
|
||||
task_id: String,
|
||||
) -> axum::body::Body {
|
||||
// SSE heartbeat comment bytes: `: heartbeat\n\n`
|
||||
// SSE spec: lines starting with `:` are comments and ignored by clients
|
||||
const HEARTBEAT_BYTES: &[u8] = b": heartbeat\n\n";
|
||||
// SSE timeout error event
|
||||
const TIMEOUT_EVENT: &[u8] = b"data: {\"error\":\"stream_timeout\",\"message\":\"upstream timed out\"}\n\n";
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
// Track how many consecutive heartbeat-only cycles have elapsed.
|
||||
// Real data resets this counter; after 2 heartbeats (30s) without
|
||||
// real data, we terminate the stream.
|
||||
let mut idle_heartbeats: u32 = 0;
|
||||
|
||||
loop {
|
||||
// tokio::select! races the next data chunk against a heartbeat timer.
|
||||
// The timer resets on every iteration, ensuring heartbeats only fire
|
||||
// during genuine idle periods.
|
||||
tokio::select! {
|
||||
biased; // prioritize data over heartbeat
|
||||
|
||||
chunk = rx.recv() => {
|
||||
match chunk {
|
||||
Some(Ok(data)) => {
|
||||
// Real data received — reset idle counter
|
||||
idle_heartbeats = 0;
|
||||
yield Ok::<bytes::Bytes, std::io::Error>(data);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!(
|
||||
"[StreamBridge] Upstream error for task {}: {}",
|
||||
task_id, e
|
||||
);
|
||||
yield Err(e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
// Channel closed = upstream finished normally
|
||||
tracing::debug!(
|
||||
"[StreamBridge] Upstream completed for task {}",
|
||||
task_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat: send SSE comment if no data for 15s
|
||||
_ = tokio::time::sleep(STREAMBRIDGE_HEARTBEAT_INTERVAL) => {
|
||||
idle_heartbeats += 1;
|
||||
tracing::trace!(
|
||||
"[StreamBridge] Heartbeat #{} for task {} (idle {}s)",
|
||||
idle_heartbeats,
|
||||
task_id,
|
||||
idle_heartbeats as u64 * STREAMBRIDGE_HEARTBEAT_INTERVAL.as_secs(),
|
||||
);
|
||||
|
||||
// After 2 consecutive heartbeats without real data (30s),
|
||||
// terminate the stream to prevent connection leaks.
|
||||
if idle_heartbeats >= 2 {
|
||||
tracing::warn!(
|
||||
"[StreamBridge] Timeout ({:?}) no real data, closing stream for task {}",
|
||||
STREAMBRIDGE_TIMEOUT,
|
||||
task_id,
|
||||
);
|
||||
yield Ok(bytes::Bytes::from_static(TIMEOUT_EVENT));
|
||||
break;
|
||||
}
|
||||
|
||||
yield Ok(bytes::Bytes::from_static(HEARTBEAT_BYTES));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Pin the stream to a Box<dyn Stream + Send> to satisfy Body::from_stream
|
||||
let boxed: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>> =
|
||||
Box::pin(stream);
|
||||
|
||||
axum::body::Body::from_stream(boxed)
|
||||
}
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
fn hash_request(body: &str) -> String {
|
||||
|
||||
Reference in New Issue
Block a user