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).
|
/// Dedup cache TTL (30 minutes).
|
||||||
const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
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)
|
// Protobuf types for Feishu WebSocket protocol (pbbp2.proto)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -181,10 +192,18 @@ impl FeishuChannel {
|
|||||||
config: FeishuChannelConfig,
|
config: FeishuChannelConfig,
|
||||||
_provider_config: LLMProviderConfig,
|
_provider_config: LLMProviderConfig,
|
||||||
) -> Result<Self, ChannelError> {
|
) -> 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 {
|
Ok(Self {
|
||||||
name,
|
name,
|
||||||
config,
|
config,
|
||||||
http_client: reqwest::Client::new(),
|
http_client,
|
||||||
running: Arc::new(RwLock::new(false)),
|
running: Arc::new(RwLock::new(false)),
|
||||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||||
connected: Arc::new(RwLock::new(false)),
|
connected: Arc::new(RwLock::new(false)),
|
||||||
@ -265,6 +284,7 @@ impl FeishuChannel {
|
|||||||
"{}/auth/v3/tenant_access_token/internal",
|
"{}/auth/v3/tenant_access_token/internal",
|
||||||
FEISHU_API_BASE
|
FEISHU_API_BASE
|
||||||
))
|
))
|
||||||
|
.timeout(HTTP_API_TIMEOUT)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"app_id": self.config.app_id,
|
"app_id": self.config.app_id,
|
||||||
@ -672,6 +692,7 @@ impl FeishuChannel {
|
|||||||
"{}/im/v1/messages/{}/reactions",
|
"{}/im/v1/messages/{}/reactions",
|
||||||
FEISHU_API_BASE, message_id
|
FEISHU_API_BASE, message_id
|
||||||
))
|
))
|
||||||
|
.timeout(HTTP_REACTION_TIMEOUT)
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"reaction_type": { "emoji_type": emoji }
|
"reaction_type": { "emoji_type": emoji }
|
||||||
|
|||||||
@ -70,6 +70,7 @@ impl SessionStore {
|
|||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
@ -244,6 +245,10 @@ impl SessionStore {
|
|||||||
|
|
||||||
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
|
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
|
||||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
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(())
|
Ok(())
|
||||||
});
|
});
|
||||||
let pool = Pool::builder().max_size(8).build(manager)?;
|
let pool = Pool::builder().max_size(8).build(manager)?;
|
||||||
@ -720,6 +725,15 @@ impl SessionStore {
|
|||||||
|row| row.get(0),
|
|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 {
|
for message in messages {
|
||||||
let media_refs_json = serde_json::to_string(&message.media_refs)?;
|
let media_refs_json = serde_json::to_string(&message.media_refs)?;
|
||||||
let tool_calls_json = message
|
let tool_calls_json = message
|
||||||
@ -727,38 +741,33 @@ impl SessionStore {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
tx.execute(
|
insert_stmt.execute(params![
|
||||||
"
|
message.id,
|
||||||
INSERT INTO messages (
|
session_id,
|
||||||
id, session_id, topic_id, seq, role, content,
|
topic_id,
|
||||||
system_context, reasoning_content, media_refs_json,
|
seq,
|
||||||
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
|
message.role,
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
message.content,
|
||||||
",
|
message.system_context,
|
||||||
params![
|
message.reasoning_content,
|
||||||
message.id,
|
media_refs_json,
|
||||||
session_id,
|
message.tool_call_id,
|
||||||
topic_id,
|
message.tool_name,
|
||||||
seq,
|
tool_calls_json,
|
||||||
message.role,
|
message.tool_duration_ms.map(|v| v as i64),
|
||||||
message.content,
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.system_context,
|
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||||
message.reasoning_content,
|
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||||
media_refs_json,
|
message
|
||||||
message.tool_call_id,
|
.usage
|
||||||
message.tool_name,
|
.as_ref()
|
||||||
tool_calls_json,
|
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
message.tool_duration_ms.map(|v| v as i64),
|
message.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.timestamp,
|
||||||
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;
|
seq += 1;
|
||||||
}
|
}
|
||||||
|
drop(insert_stmt);
|
||||||
|
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let user_msg_count: i64 = messages
|
let user_msg_count: i64 = messages
|
||||||
@ -850,14 +859,16 @@ impl SessionStore {
|
|||||||
let mut inserted_count = 0_i64;
|
let mut inserted_count = 0_i64;
|
||||||
let mut active_user_turn_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 {
|
for message in &new_messages {
|
||||||
if message.role == "user" {
|
if message.role == "user" {
|
||||||
active_user_turn_count += 1;
|
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;
|
next_seq += 1;
|
||||||
inserted_count += 1;
|
inserted_count += 1;
|
||||||
}
|
}
|
||||||
|
drop(insert_stmt);
|
||||||
|
|
||||||
// Delete all old messages (including delta messages that were just re-inserted)
|
// Delete all old messages (including delta messages that were just re-inserted)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@ -905,13 +916,15 @@ impl SessionStore {
|
|||||||
|
|
||||||
// Insert new messages with sequential seq numbers
|
// Insert new messages with sequential seq numbers
|
||||||
let mut active_user_turn_count = 0_i64;
|
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() {
|
for (i, message) in messages.iter().enumerate() {
|
||||||
let seq = (i + 1) as i64;
|
let seq = (i + 1) as i64;
|
||||||
if message.role == "user" {
|
if message.role == "user" {
|
||||||
active_user_turn_count += 1;
|
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(
|
tx.execute(
|
||||||
"
|
"
|
||||||
@ -972,10 +985,12 @@ impl SessionStore {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
|
||||||
for (i, message) in messages.iter().enumerate() {
|
for (i, message) in messages.iter().enumerate() {
|
||||||
let seq = start_seq + i as i64;
|
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.
|
// Update this topic's message_count and timestamps.
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@ -1070,10 +1085,12 @@ impl SessionStore {
|
|||||||
params![session_id],
|
params![session_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
|
||||||
for (i, message) in summaries.iter().enumerate() {
|
for (i, message) in summaries.iter().enumerate() {
|
||||||
let seq = start_seq + i as i64;
|
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 全部消息,含被压缩的原始消息)
|
// 更新 topic / session 计数(基于该 topic 全部消息,含被压缩的原始消息)
|
||||||
let topic_count: i64 = tx.query_row(
|
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])?;
|
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
|
||||||
|
|
||||||
// Insert new todos
|
// 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 {
|
for item in items {
|
||||||
tx.execute(
|
insert_stmt.execute(params![
|
||||||
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
item.id,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
scope_key,
|
||||||
params![
|
item.session_id,
|
||||||
item.id,
|
item.topic_id,
|
||||||
scope_key,
|
item.content,
|
||||||
item.session_id,
|
item.status,
|
||||||
item.topic_id,
|
item.priority,
|
||||||
item.content,
|
item.created_at,
|
||||||
item.status,
|
now,
|
||||||
item.priority,
|
item.created_by_message_id,
|
||||||
item.created_at,
|
])?;
|
||||||
now,
|
|
||||||
item.created_by_message_id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
drop(insert_stmt);
|
||||||
|
|
||||||
// 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。
|
// 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。
|
||||||
let mut stmt = tx.prepare(
|
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"))
|
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(
|
fn insert_message_with_seq(
|
||||||
conn: &rusqlite::Transaction<'_>,
|
stmt: &mut rusqlite::Statement<'_>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
seq: i64,
|
seq: i64,
|
||||||
message: &ChatMessage,
|
message: &ChatMessage,
|
||||||
@ -2279,33 +2315,28 @@ fn insert_message_with_seq(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
conn.execute(
|
stmt.execute(params![
|
||||||
"
|
message.id,
|
||||||
INSERT INTO messages (
|
session_id,
|
||||||
id, session_id, seq, role, content,
|
seq,
|
||||||
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
|
message.role,
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
message.content,
|
||||||
",
|
message.system_context,
|
||||||
params![
|
message.reasoning_content,
|
||||||
message.id,
|
media_refs_json,
|
||||||
session_id,
|
message.tool_call_id,
|
||||||
seq,
|
message.tool_name,
|
||||||
message.role,
|
tool_calls_json,
|
||||||
message.content,
|
message.tool_duration_ms.map(|v| v as i64),
|
||||||
message.system_context,
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.reasoning_content,
|
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||||
media_refs_json,
|
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||||
message.tool_call_id,
|
message
|
||||||
message.tool_name,
|
.usage
|
||||||
tool_calls_json,
|
.as_ref()
|
||||||
message.tool_duration_ms.map(|v| v as i64),
|
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.timestamp,
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2315,7 +2346,7 @@ fn insert_message_with_seq(
|
|||||||
/// preserving topic association (the plain `insert_message_with_seq` would
|
/// preserving topic association (the plain `insert_message_with_seq` would
|
||||||
/// set topic_id to NULL).
|
/// set topic_id to NULL).
|
||||||
fn insert_message_with_topic_seq(
|
fn insert_message_with_topic_seq(
|
||||||
conn: &rusqlite::Transaction<'_>,
|
stmt: &mut rusqlite::Statement<'_>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
topic_id: &str,
|
topic_id: &str,
|
||||||
seq: i64,
|
seq: i64,
|
||||||
@ -2327,34 +2358,29 @@ fn insert_message_with_topic_seq(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
conn.execute(
|
stmt.execute(params![
|
||||||
"
|
message.id,
|
||||||
INSERT INTO messages (
|
session_id,
|
||||||
id, session_id, topic_id, seq, role, content,
|
topic_id,
|
||||||
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
|
seq,
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
message.role,
|
||||||
",
|
message.content,
|
||||||
params![
|
message.system_context,
|
||||||
message.id,
|
message.reasoning_content,
|
||||||
session_id,
|
media_refs_json,
|
||||||
topic_id,
|
message.tool_call_id,
|
||||||
seq,
|
message.tool_name,
|
||||||
message.role,
|
tool_calls_json,
|
||||||
message.content,
|
message.tool_duration_ms.map(|v| v as i64),
|
||||||
message.system_context,
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.reasoning_content,
|
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
||||||
media_refs_json,
|
message.usage.as_ref().map(|u| u.total_tokens as i64),
|
||||||
message.tool_call_id,
|
message
|
||||||
message.tool_name,
|
.usage
|
||||||
tool_calls_json,
|
.as_ref()
|
||||||
message.tool_duration_ms.map(|v| v as i64),
|
.and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.timestamp,
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,38 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use futures_util::StreamExt;
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::text::take_prefix_chars;
|
use crate::text::take_prefix_chars;
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
|
|
||||||
|
/// 未配置响应大小限制时的硬性下载上限(防止无限响应打满内存)。
|
||||||
|
const HARD_DOWNLOAD_CAP_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
pub struct WebFetchTool {
|
pub struct WebFetchTool {
|
||||||
max_response_size: usize,
|
max_response_size: usize,
|
||||||
timeout_secs: u64,
|
|
||||||
user_agent: String,
|
user_agent: String,
|
||||||
|
/// 长生命周期 HTTP 客户端(连接池 + TLS 上下文 + 超时配置),构造一次全程复用。
|
||||||
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebFetchTool {
|
impl WebFetchTool {
|
||||||
pub fn new(max_response_size: usize, timeout_secs: u64) -> Self {
|
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 {
|
Self {
|
||||||
max_response_size,
|
max_response_size,
|
||||||
timeout_secs,
|
|
||||||
user_agent: "Mozilla/5.0 (compatible; Picobot/1.0)".to_string(),
|
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> {
|
/// 下载字节上限:字符上限 × 4(UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
|
||||||
let client = reqwest::Client::builder()
|
/// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
|
||||||
.timeout(Duration::from_secs(self.timeout_secs))
|
fn download_byte_limit(&self) -> usize {
|
||||||
// 禁用重定向:validate_url 只校验初始 URL 的 host,
|
if self.max_response_size == 0 {
|
||||||
// 若跟随 302 跳转,攻击者可用公网 URL 重定向到
|
HARD_DOWNLOAD_CAP_BYTES
|
||||||
// 169.254.169.254(云元数据)或 127.0.0.1 等内网地址,
|
} else {
|
||||||
// 绕过 is_private_host 的 SSRF 防护。
|
self.max_response_size
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.saturating_mul(4)
|
||||||
.build()
|
.min(HARD_DOWNLOAD_CAP_BYTES)
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_content(&self, url: &str) -> Result<String, String> {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
reqwest::header::USER_AGENT,
|
reqwest::header::USER_AGENT,
|
||||||
self.user_agent.parse().unwrap(),
|
self.user_agent.parse().unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client
|
let response = self
|
||||||
|
.client
|
||||||
.get(url)
|
.get(url)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.send()
|
.send()
|
||||||
@ -93,19 +110,13 @@ impl WebFetchTool {
|
|||||||
|
|
||||||
// Handle HTML content
|
// Handle HTML content
|
||||||
if content_type.contains("text/html") {
|
if content_type.contains("text/html") {
|
||||||
let html = response
|
let html = read_body_limited(response, self.download_byte_limit()).await?;
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
|
||||||
return Ok(self.extract_text_from_html(&html));
|
return Ok(self.extract_text_from_html(&html));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle JSON content
|
// Handle JSON content
|
||||||
if content_type.contains("application/json") {
|
if content_type.contains("application/json") {
|
||||||
let text = response
|
let text = read_body_limited(response, self.download_byte_limit()).await?;
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
|
||||||
// Pretty print JSON
|
// Pretty print JSON
|
||||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
return Ok(serde_json::to_string_pretty(&parsed).unwrap_or(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
|
// For other content types, return raw text
|
||||||
response
|
read_body_limited(response, self.download_byte_limit()).await
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to read response: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_text_from_html(&self, html: &str) -> String {
|
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 {
|
fn strip_tag(s: &str, tag_name: &str) -> String {
|
||||||
let open = format!("<{}>", tag_name);
|
let open = format!("<{}>", tag_name);
|
||||||
let close = format!("</{}>", tag_name);
|
let close = format!("</{}>", tag_name);
|
||||||
|
|||||||
@ -122,6 +122,11 @@ function App() {
|
|||||||
finishStreaming,
|
finishStreaming,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
||||||
|
// 子代理视图的语义身份:taskId。
|
||||||
|
// subAgentView 对象在流式期间每帧都会因 messages 更新而换引用,
|
||||||
|
// effect/callback 的依赖必须用此原始值,否则会被每帧无效重跑。
|
||||||
|
const subAgentTaskId = subAgentView?.taskId;
|
||||||
|
|
||||||
const { status, sendMessage } = useWebSocket({
|
const { status, sendMessage } = useWebSocket({
|
||||||
url: wsUrl,
|
url: wsUrl,
|
||||||
onMessage: handleServerMessage,
|
onMessage: handleServerMessage,
|
||||||
@ -319,9 +324,9 @@ function App() {
|
|||||||
if (status !== 'connected') return;
|
if (status !== 'connected') return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (subAgentView) {
|
if (subAgentTaskId) {
|
||||||
// 子代理视图:发 load_task_messages 刷新子代理 token_stats
|
// 子代理视图:发 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);
|
handleCommand(cmd);
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||||
} else {
|
} else {
|
||||||
@ -334,7 +339,7 @@ function App() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [topicRefreshTrigger, status, subAgentView, handleCommand, sendMessage, requestTopicList]);
|
}, [topicRefreshTrigger, status, subAgentTaskId, handleCommand, sendMessage, requestTopicList]);
|
||||||
|
|
||||||
// 当前选中 topic(用于右侧 Sidebar token 统计面板)
|
// 当前选中 topic(用于右侧 Sidebar token 统计面板)
|
||||||
const currentTopic = useMemo(
|
const currentTopic = useMemo(
|
||||||
@ -536,19 +541,19 @@ function App() {
|
|||||||
const prevTodoTriggerRef = useRef<string>('');
|
const prevTodoTriggerRef = useRef<string>('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'connected') return;
|
if (status !== 'connected') return;
|
||||||
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`;
|
const key = `${selectedTopic ?? ''}|${subAgentTaskId ?? ''}`;
|
||||||
if (key === prevTodoTriggerRef.current) return;
|
if (key === prevTodoTriggerRef.current) return;
|
||||||
prevTodoTriggerRef.current = key;
|
prevTodoTriggerRef.current = key;
|
||||||
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
|
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
|
||||||
const todoCmd = subAgentView?.taskId
|
const todoCmd = subAgentTaskId
|
||||||
? requestSubAgentTodoList(subAgentView.taskId)
|
? requestSubAgentTodoList(subAgentTaskId)
|
||||||
: requestTodoList();
|
: requestTodoList();
|
||||||
handleCommand(todoCmd);
|
handleCommand(todoCmd);
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
|
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
|
||||||
}, [
|
}, [
|
||||||
status,
|
status,
|
||||||
selectedTopic,
|
selectedTopic,
|
||||||
subAgentView,
|
subAgentTaskId,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
requestTodoList,
|
requestTodoList,
|
||||||
@ -578,8 +583,8 @@ function App() {
|
|||||||
|
|
||||||
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
|
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
|
||||||
const refreshTodoList = useCallback((): Command => {
|
const refreshTodoList = useCallback((): Command => {
|
||||||
return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList();
|
return subAgentTaskId ? requestSubAgentTodoList(subAgentTaskId) : requestTodoList();
|
||||||
}, [subAgentView, requestTodoList, requestSubAgentTodoList]);
|
}, [subAgentTaskId, requestTodoList, requestSubAgentTodoList]);
|
||||||
|
|
||||||
// 点击待办项后滚动到对应消息
|
// 点击待办项后滚动到对应消息
|
||||||
const handleTodoClick = useCallback(
|
const handleTodoClick = useCallback(
|
||||||
|
|||||||
@ -20,10 +20,96 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import type { Components } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
|
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
|
||||||
import { ToolDetailModal } from './ToolDetailModal';
|
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({
|
function StatusIcon({
|
||||||
status,
|
status,
|
||||||
@ -683,7 +769,7 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">输出</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">
|
<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}
|
{taskResult.output}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@ -825,101 +911,8 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
{message.content.trim() && (
|
{message.content.trim() && (
|
||||||
<div className="markdown-content text-[15px] leading-6">
|
<div className="markdown-content text-[15px] leading-6">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
components={{
|
components={MARKDOWN_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>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
// 模块级常量:保持引用稳定,避免每次渲染重建数组
|
||||||
|
const REMARK_PLUGINS = [remarkGfm];
|
||||||
|
|
||||||
interface ToolDetailModalProps {
|
interface ToolDetailModalProps {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
status: string;
|
status: string;
|
||||||
@ -127,7 +130,7 @@ export function ToolDetailModal({
|
|||||||
{resultContent ? '结果' : '输出'}
|
{resultContent ? '结果' : '输出'}
|
||||||
</div>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user