perf: 第一批性能修复——HTTP 超时、SQLite 写入优化、流式限长、前端减少无效重跑
后端: - 飞书通道 HTTP client 增加 connect/全局超时,token 刷新与 reaction 叠加紧超时,消除无限挂起风险 - SQLite WAL 下设置 synchronous=NORMAL(per-connection with_init),6 处批量写入循环改用 prepare_cached 复用预编译语句 - web_fetch 复用长生命周期 HTTP client,响应体改为流式限长读取(上限=字符限额x4,封顶 32MB),替代全量读完再截断 前端: - App.tsx effect/callback 依赖由 subAgentView 对象改为 subAgentTaskId 原始值,修复子代理流式期间 token 统计 500ms 定时器被每帧重置永不触发的问题 - MessageBubble/ToolDetailModal 的 ReactMarkdown components 与 remarkPlugins 提升为模块级常量,避免流式渲染期间每帧重建
This commit is contained in:
parent
accdfeed3b
commit
e7deac6950
@ -30,6 +30,17 @@ const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
|
||||
/// Dedup cache TTL (30 minutes).
|
||||
const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// TCP/TLS 建连超时:避免半开连接或黑洞路由导致无限等待。
|
||||
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// 全局请求超时上限:覆盖媒体上传/下载等大体积传输,
|
||||
/// 保证任何请求(含数十 MB 文件)的等待时间有界。
|
||||
const HTTP_TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// 控制面小请求(token 刷新)的紧超时:挂起会阻塞所有出站消息。
|
||||
const HTTP_API_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// reaction 请求的紧超时:该调用发生在 WS 帧处理循环内,
|
||||
/// 挂起会导致后续消息无法在飞书要求的 3 秒内 ACK。
|
||||
const HTTP_REACTION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Protobuf types for Feishu WebSocket protocol (pbbp2.proto)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -181,10 +192,18 @@ impl FeishuChannel {
|
||||
config: FeishuChannelConfig,
|
||||
_provider_config: LLMProviderConfig,
|
||||
) -> Result<Self, ChannelError> {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
||||
.timeout(HTTP_TOTAL_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ChannelError::Other(format!("Failed to build Feishu HTTP client: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
config,
|
||||
http_client: reqwest::Client::new(),
|
||||
http_client,
|
||||
running: Arc::new(RwLock::new(false)),
|
||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||
connected: Arc::new(RwLock::new(false)),
|
||||
@ -265,6 +284,7 @@ impl FeishuChannel {
|
||||
"{}/auth/v3/tenant_access_token/internal",
|
||||
FEISHU_API_BASE
|
||||
))
|
||||
.timeout(HTTP_API_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"app_id": self.config.app_id,
|
||||
@ -672,6 +692,7 @@ impl FeishuChannel {
|
||||
"{}/im/v1/messages/{}/reactions",
|
||||
FEISHU_API_BASE, message_id
|
||||
))
|
||||
.timeout(HTTP_REACTION_TIMEOUT)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({
|
||||
"reaction_type": { "emoji_type": emoji }
|
||||
|
||||
@ -70,6 +70,7 @@ impl SessionStore {
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
@ -244,6 +245,10 @@ impl SessionStore {
|
||||
|
||||
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
|
||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||
// synchronous 是 per-connection PRAGMA(不随库文件持久化),
|
||||
// 池内每个连接都必须单独设置。WAL + NORMAL 是 SQLite 官方推荐组合:
|
||||
// 消除每次 commit 的 WAL full fsync,仅 checkpoint 时同步。
|
||||
c.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
Ok(())
|
||||
});
|
||||
let pool = Pool::builder().max_size(8).build(manager)?;
|
||||
@ -720,6 +725,15 @@ impl SessionStore {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare_cached(
|
||||
"
|
||||
INSERT INTO messages (
|
||||
id, session_id, topic_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json,
|
||||
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
",
|
||||
)?;
|
||||
for message in messages {
|
||||
let media_refs_json = serde_json::to_string(&message.media_refs)?;
|
||||
let tool_calls_json = message
|
||||
@ -727,38 +741,33 @@ impl SessionStore {
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
tx.execute(
|
||||
"
|
||||
INSERT INTO messages (
|
||||
id, session_id, topic_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json,
|
||||
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
session_id,
|
||||
topic_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||
message.timestamp,
|
||||
],
|
||||
)?;
|
||||
insert_stmt.execute(params![
|
||||
message.id,
|
||||
session_id,
|
||||
topic_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||
message.timestamp,
|
||||
])?;
|
||||
seq += 1;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
let now = current_timestamp();
|
||||
let user_msg_count: i64 = messages
|
||||
@ -850,14 +859,16 @@ impl SessionStore {
|
||||
let mut inserted_count = 0_i64;
|
||||
let mut active_user_turn_count = 0_i64;
|
||||
|
||||
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_SQL)?;
|
||||
for message in &new_messages {
|
||||
if message.role == "user" {
|
||||
active_user_turn_count += 1;
|
||||
}
|
||||
insert_message_with_seq(&tx, session_id, next_seq, message)?;
|
||||
insert_message_with_seq(&mut insert_stmt, session_id, next_seq, message)?;
|
||||
next_seq += 1;
|
||||
inserted_count += 1;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
// Delete all old messages (including delta messages that were just re-inserted)
|
||||
tx.execute(
|
||||
@ -905,13 +916,15 @@ impl SessionStore {
|
||||
|
||||
// Insert new messages with sequential seq numbers
|
||||
let mut active_user_turn_count = 0_i64;
|
||||
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_SQL)?;
|
||||
for (i, message) in messages.iter().enumerate() {
|
||||
let seq = (i + 1) as i64;
|
||||
if message.role == "user" {
|
||||
active_user_turn_count += 1;
|
||||
}
|
||||
insert_message_with_seq(&tx, session_id, seq, message)?;
|
||||
insert_message_with_seq(&mut insert_stmt, session_id, seq, message)?;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
tx.execute(
|
||||
"
|
||||
@ -972,10 +985,12 @@ impl SessionStore {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
|
||||
for (i, message) in messages.iter().enumerate() {
|
||||
let seq = start_seq + i as i64;
|
||||
insert_message_with_topic_seq(&tx, session_id, topic_id, seq, message)?;
|
||||
insert_message_with_topic_seq(&mut insert_stmt, session_id, topic_id, seq, message)?;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
// Update this topic's message_count and timestamps.
|
||||
tx.execute(
|
||||
@ -1070,10 +1085,12 @@ impl SessionStore {
|
||||
params![session_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
|
||||
for (i, message) in summaries.iter().enumerate() {
|
||||
let seq = start_seq + i as i64;
|
||||
insert_message_with_topic_seq(&tx, session_id, topic_id, seq, message)?;
|
||||
insert_message_with_topic_seq(&mut insert_stmt, session_id, topic_id, seq, message)?;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
// 更新 topic / session 计数(基于该 topic 全部消息,含被压缩的原始消息)
|
||||
let topic_count: i64 = tx.query_row(
|
||||
@ -2031,24 +2048,25 @@ impl SessionStore {
|
||||
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
|
||||
|
||||
// Insert new todos
|
||||
let mut insert_stmt = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
)?;
|
||||
for item in items {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
item.id,
|
||||
scope_key,
|
||||
item.session_id,
|
||||
item.topic_id,
|
||||
item.content,
|
||||
item.status,
|
||||
item.priority,
|
||||
item.created_at,
|
||||
now,
|
||||
item.created_by_message_id,
|
||||
],
|
||||
)?;
|
||||
insert_stmt.execute(params![
|
||||
item.id,
|
||||
scope_key,
|
||||
item.session_id,
|
||||
item.topic_id,
|
||||
item.content,
|
||||
item.status,
|
||||
item.priority,
|
||||
item.created_at,
|
||||
now,
|
||||
item.created_by_message_id,
|
||||
])?;
|
||||
}
|
||||
drop(insert_stmt);
|
||||
|
||||
// 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。
|
||||
let mut stmt = tx.prepare(
|
||||
@ -2267,8 +2285,26 @@ fn default_session_db_path() -> Result<PathBuf, std::io::Error> {
|
||||
Ok(home.join(".picobot").join("storage").join("sessions.db"))
|
||||
}
|
||||
|
||||
/// 批量插入消息的预编译 SQL(17 列,无 topic_id / cached_tokens)。
|
||||
/// 由 `insert_message_with_seq` 使用,循环写入前 `prepare_cached` 一次复用。
|
||||
const INSERT_MESSAGE_SQL: &str = "
|
||||
INSERT INTO messages (
|
||||
id, session_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
";
|
||||
|
||||
/// 批量插入消息的预编译 SQL(18 列,含 topic_id,无 cached_tokens)。
|
||||
/// 由 `insert_message_with_topic_seq` 使用,循环写入前 `prepare_cached` 一次复用。
|
||||
const INSERT_MESSAGE_TOPIC_SQL: &str = "
|
||||
INSERT INTO messages (
|
||||
id, session_id, topic_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
";
|
||||
|
||||
fn insert_message_with_seq(
|
||||
conn: &rusqlite::Transaction<'_>,
|
||||
stmt: &mut rusqlite::Statement<'_>,
|
||||
session_id: &str,
|
||||
seq: i64,
|
||||
message: &ChatMessage,
|
||||
@ -2279,33 +2315,28 @@ fn insert_message_with_seq(
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
conn.execute(
|
||||
"
|
||||
INSERT INTO messages (
|
||||
id, session_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
session_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.timestamp,
|
||||
],
|
||||
)?;
|
||||
stmt.execute(params![
|
||||
message.id,
|
||||
session_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.timestamp,
|
||||
])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -2315,7 +2346,7 @@ fn insert_message_with_seq(
|
||||
/// preserving topic association (the plain `insert_message_with_seq` would
|
||||
/// set topic_id to NULL).
|
||||
fn insert_message_with_topic_seq(
|
||||
conn: &rusqlite::Transaction<'_>,
|
||||
stmt: &mut rusqlite::Statement<'_>,
|
||||
session_id: &str,
|
||||
topic_id: &str,
|
||||
seq: i64,
|
||||
@ -2327,34 +2358,29 @@ fn insert_message_with_topic_seq(
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
conn.execute(
|
||||
"
|
||||
INSERT INTO messages (
|
||||
id, session_id, topic_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
session_id,
|
||||
topic_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.timestamp,
|
||||
],
|
||||
)?;
|
||||
stmt.execute(params![
|
||||
message.id,
|
||||
session_id,
|
||||
topic_id,
|
||||
seq,
|
||||
message.role,
|
||||
message.content,
|
||||
message.system_context,
|
||||
message.reasoning_content,
|
||||
media_refs_json,
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||
message
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||
message.timestamp,
|
||||
])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,38 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::text::take_prefix_chars;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
/// 未配置响应大小限制时的硬性下载上限(防止无限响应打满内存)。
|
||||
const HARD_DOWNLOAD_CAP_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
pub struct WebFetchTool {
|
||||
max_response_size: usize,
|
||||
timeout_secs: u64,
|
||||
user_agent: String,
|
||||
/// 长生命周期 HTTP 客户端(连接池 + TLS 上下文 + 超时配置),构造一次全程复用。
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WebFetchTool {
|
||||
pub fn new(max_response_size: usize, timeout_secs: u64) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
// 禁用重定向:validate_url 只校验初始 URL 的 host,
|
||||
// 若跟随 302 跳转,攻击者可用公网 URL 重定向到
|
||||
// 169.254.169.254(云元数据)或 127.0.0.1 等内网地址,
|
||||
// 绕过 is_private_host 的 SSRF 防护。
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("valid HTTP client configuration");
|
||||
Self {
|
||||
max_response_size,
|
||||
timeout_secs,
|
||||
user_agent: "Mozilla/5.0 (compatible; Picobot/1.0)".to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,24 +75,27 @@ impl WebFetchTool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_content(&self, url: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
// 禁用重定向:validate_url 只校验初始 URL 的 host,
|
||||
// 若跟随 302 跳转,攻击者可用公网 URL 重定向到
|
||||
// 169.254.169.254(云元数据)或 127.0.0.1 等内网地址,
|
||||
// 绕过 is_private_host 的 SSRF 防护。
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
/// 下载字节上限:字符上限 × 4(UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
|
||||
/// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
|
||||
fn download_byte_limit(&self) -> usize {
|
||||
if self.max_response_size == 0 {
|
||||
HARD_DOWNLOAD_CAP_BYTES
|
||||
} else {
|
||||
self.max_response_size
|
||||
.saturating_mul(4)
|
||||
.min(HARD_DOWNLOAD_CAP_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_content(&self, url: &str) -> Result<String, String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
self.user_agent.parse().unwrap(),
|
||||
);
|
||||
|
||||
let response = client
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
@ -93,19 +110,13 @@ impl WebFetchTool {
|
||||
|
||||
// Handle HTML content
|
||||
if content_type.contains("text/html") {
|
||||
let html = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
let html = read_body_limited(response, self.download_byte_limit()).await?;
|
||||
return Ok(self.extract_text_from_html(&html));
|
||||
}
|
||||
|
||||
// Handle JSON content
|
||||
if content_type.contains("application/json") {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
let text = read_body_limited(response, self.download_byte_limit()).await?;
|
||||
// Pretty print JSON
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
return Ok(serde_json::to_string_pretty(&parsed).unwrap_or(text));
|
||||
@ -114,10 +125,7 @@ impl WebFetchTool {
|
||||
}
|
||||
|
||||
// For other content types, return raw text
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))
|
||||
read_body_limited(response, self.download_byte_limit()).await
|
||||
}
|
||||
|
||||
fn extract_text_from_html(&self, html: &str) -> String {
|
||||
@ -175,6 +183,29 @@ impl WebFetchTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式读取响应体,累计达到 `max_bytes` 即提前中止下载。
|
||||
/// 限制在下载过程中生效(而非全量载入后截断),防止超大响应耗尽内存。
|
||||
async fn read_body_limited(
|
||||
response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<String, String> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
let remaining = max_bytes.saturating_sub(body.len());
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if chunk.len() > remaining {
|
||||
body.extend_from_slice(&chunk[..remaining]);
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
fn strip_tag(s: &str, tag_name: &str) -> String {
|
||||
let open = format!("<{}>", tag_name);
|
||||
let close = format!("</{}>", tag_name);
|
||||
|
||||
@ -122,6 +122,11 @@ function App() {
|
||||
finishStreaming,
|
||||
} = useChat();
|
||||
|
||||
// 子代理视图的语义身份:taskId。
|
||||
// subAgentView 对象在流式期间每帧都会因 messages 更新而换引用,
|
||||
// effect/callback 的依赖必须用此原始值,否则会被每帧无效重跑。
|
||||
const subAgentTaskId = subAgentView?.taskId;
|
||||
|
||||
const { status, sendMessage } = useWebSocket({
|
||||
url: wsUrl,
|
||||
onMessage: handleServerMessage,
|
||||
@ -319,9 +324,9 @@ function App() {
|
||||
if (status !== 'connected') return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (subAgentView) {
|
||||
if (subAgentTaskId) {
|
||||
// 子代理视图:发 load_task_messages 刷新子代理 token_stats
|
||||
const cmd = { type: 'load_task_messages' as const, task_id: subAgentView.taskId };
|
||||
const cmd = { type: 'load_task_messages' as const, task_id: subAgentTaskId };
|
||||
handleCommand(cmd);
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||
} else {
|
||||
@ -334,7 +339,7 @@ function App() {
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [topicRefreshTrigger, status, subAgentView, handleCommand, sendMessage, requestTopicList]);
|
||||
}, [topicRefreshTrigger, status, subAgentTaskId, handleCommand, sendMessage, requestTopicList]);
|
||||
|
||||
// 当前选中 topic(用于右侧 Sidebar token 统计面板)
|
||||
const currentTopic = useMemo(
|
||||
@ -536,19 +541,19 @@ function App() {
|
||||
const prevTodoTriggerRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`;
|
||||
const key = `${selectedTopic ?? ''}|${subAgentTaskId ?? ''}`;
|
||||
if (key === prevTodoTriggerRef.current) return;
|
||||
prevTodoTriggerRef.current = key;
|
||||
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
|
||||
const todoCmd = subAgentView?.taskId
|
||||
? requestSubAgentTodoList(subAgentView.taskId)
|
||||
const todoCmd = subAgentTaskId
|
||||
? requestSubAgentTodoList(subAgentTaskId)
|
||||
: requestTodoList();
|
||||
handleCommand(todoCmd);
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
|
||||
}, [
|
||||
status,
|
||||
selectedTopic,
|
||||
subAgentView,
|
||||
subAgentTaskId,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
requestTodoList,
|
||||
@ -578,8 +583,8 @@ function App() {
|
||||
|
||||
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
|
||||
const refreshTodoList = useCallback((): Command => {
|
||||
return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList();
|
||||
}, [subAgentView, requestTodoList, requestSubAgentTodoList]);
|
||||
return subAgentTaskId ? requestSubAgentTodoList(subAgentTaskId) : requestTodoList();
|
||||
}, [subAgentTaskId, requestTodoList, requestSubAgentTodoList]);
|
||||
|
||||
// 点击待办项后滚动到对应消息
|
||||
const handleTodoClick = useCallback(
|
||||
|
||||
@ -20,10 +20,96 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
|
||||
import { ToolDetailModal } from './ToolDetailModal';
|
||||
|
||||
// 模块级常量:保持引用稳定,避免流式渲染期间每帧重建
|
||||
// (react-markdown 收到新的 components/plugins 引用会重走内部映射与解析)。
|
||||
const REMARK_PLUGINS = [remarkGfm];
|
||||
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
// 自定义代码块渲染
|
||||
code({ className, children, ...props }) {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
|
||||
<code className={`${className} font-mono text-xs`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
// 标题样式
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">{children}</h3>
|
||||
),
|
||||
// 段落
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
// 列表
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
|
||||
// 链接
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent-cyan)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 表格
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-[var(--overlay-subtle)]">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-[var(--border-color)] px-2 py-1">{children}</td>
|
||||
),
|
||||
// 引用块
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// 分隔线
|
||||
hr: () => <hr className="border-[var(--border-color)] my-3" />,
|
||||
// 加粗和斜体
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
|
||||
};
|
||||
|
||||
// 状态图标组件
|
||||
function StatusIcon({
|
||||
status,
|
||||
@ -683,7 +769,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
<div>
|
||||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">输出</div>
|
||||
<div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
|
||||
{taskResult.output}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@ -825,101 +911,8 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
{message.content.trim() && (
|
||||
<div className="markdown-content text-[15px] leading-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 自定义代码块渲染
|
||||
code({ className, children, ...props }) {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
|
||||
<code className={`${className} font-mono text-xs`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
// 标题样式
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
// 段落
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
// 列表
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
|
||||
// 链接
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent-cyan)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 表格
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-[var(--overlay-subtle)]">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-[var(--border-color)] px-2 py-1">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
// 引用块
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// 分隔线
|
||||
hr: () => <hr className="border-[var(--border-color)] my-3" />,
|
||||
// 加粗和斜体
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-[var(--text-secondary)]">{children}</em>
|
||||
),
|
||||
}}
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@ -3,6 +3,9 @@ import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// 模块级常量:保持引用稳定,避免每次渲染重建数组
|
||||
const REMARK_PLUGINS = [remarkGfm];
|
||||
|
||||
interface ToolDetailModalProps {
|
||||
toolName: string;
|
||||
status: string;
|
||||
@ -127,7 +130,7 @@ export function ToolDetailModal({
|
||||
{resultContent ? '结果' : '输出'}
|
||||
</div>
|
||||
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formattedContent}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{formattedContent}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user