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:
oudecheng 2026-08-17 22:29:14 +08:00
parent accdfeed3b
commit e7deac6950
6 changed files with 318 additions and 239 deletions

View File

@ -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 }

View File

@ -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"))
}
/// 批量插入消息的预编译 SQL17 列,无 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)
";
/// 批量插入消息的预编译 SQL18 列,含 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(())
}

View File

@ -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))?;
/// 下载字节上限:字符上限 × 4UTF-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);

View File

@ -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(

View File

@ -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>

View File

@ -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>
)}