diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 3e78319..0b856a4 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -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 { + 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 } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 5507144..1a810aa 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -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 { 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(()) } diff --git a/src/tools/web_fetch.rs b/src/tools/web_fetch.rs index dec75b4..05654c6 100644 --- a/src/tools/web_fetch.rs +++ b/src/tools/web_fetch.rs @@ -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 { - 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 { 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::(&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 { + let mut stream = response.bytes_stream(); + let mut body: Vec = 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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7b049e2..7b5e2f5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(''); 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( diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index 5dfcace..54214a3 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -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 ( + + {children} + + ); + } + return ( +
+        
+          {children}
+        
+      
+ ); + }, + // 标题样式 + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + // 段落 + p: ({ children }) =>

{children}

, + // 列表 + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + // 链接 + a: ({ href, children }) => ( + + {children} + + ), + // 表格 + table: ({ children }) => ( + {children}
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + // 引用块 + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // 分隔线 + hr: () =>
    , + // 加粗和斜体 + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, +}; + // 状态图标组件 function StatusIcon({ status, @@ -683,7 +769,7 @@ export const MessageBubble = memo(function MessageBubble({
    输出
    - + {taskResult.output}
    @@ -825,101 +911,8 @@ export const MessageBubble = memo(function MessageBubble({ {message.content.trim() && (
    - {children} - - ); - } - return ( -
    -                            
    -                              {children}
    -                            
    -                          
    - ); - }, - // 标题样式 - h1: ({ children }) => ( -

    - {children} -

    - ), - h2: ({ children }) => ( -

    - {children} -

    - ), - h3: ({ children }) => ( -

    - {children} -

    - ), - // 段落 - p: ({ children }) =>

    {children}

    , - // 列表 - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      - {children} -
    - ), - li: ({ children }) =>
  • {children}
  • , - // 链接 - a: ({ href, children }) => ( - - {children} - - ), - // 表格 - table: ({ children }) => ( - {children}
    - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - // 引用块 - blockquote: ({ children }) => ( -
    - {children} -
    - ), - // 分隔线 - hr: () =>
    , - // 加粗和斜体 - strong: ({ children }) => ( - {children} - ), - em: ({ children }) => ( - {children} - ), - }} + remarkPlugins={REMARK_PLUGINS} + components={MARKDOWN_COMPONENTS} > {message.content}
    diff --git a/web/src/components/Chat/ToolDetailModal.tsx b/web/src/components/Chat/ToolDetailModal.tsx index 56dd1b9..01b8048 100644 --- a/web/src/components/Chat/ToolDetailModal.tsx +++ b/web/src/components/Chat/ToolDetailModal.tsx @@ -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 ? '结果' : '输出'}
    - {formattedContent} + {formattedContent}
    )}