diff --git a/MeAgent/navigation/Menu.js b/MeAgent/navigation/Menu.js index 1aa9a697..32b400bb 100644 --- a/MeAgent/navigation/Menu.js +++ b/MeAgent/navigation/Menu.js @@ -204,7 +204,10 @@ function CustomDrawerContent({ return ( navigation.navigate(item.navigateTo)} + onPress={() => { + navigation.navigate(item.navigateTo); + navigation.closeDrawer(); + }} mb={2} > {isFocused ? ( diff --git a/MeAgent/navigation/Screens.js b/MeAgent/navigation/Screens.js index a3be405f..bd7185a5 100644 --- a/MeAgent/navigation/Screens.js +++ b/MeAgent/navigation/Screens.js @@ -266,12 +266,13 @@ function ArticlesStack(props) { } // 事件中心导航栈 -function EventsStack(props) { +function EventsStack({ navigation }) { return ( { + // 在列表页允许 Drawer 手势 + navigation.setOptions({ swipeEnabled: true }); + }, + }} /> { + // 在详情页禁用 Drawer 手势,让滑动只触发返回 + navigation.setOptions({ swipeEnabled: false }); + }, }} /> { + navigation.setOptions({ swipeEnabled: false }); + }, }} /> diff --git a/MeAgent/src/constants/agentConstants.js b/MeAgent/src/constants/agentConstants.js index cbfa9ce5..a73a4514 100644 --- a/MeAgent/src/constants/agentConstants.js +++ b/MeAgent/src/constants/agentConstants.js @@ -27,6 +27,7 @@ export const SSEEventTypes = { STEP_START: 'step_start', STEP_COMPLETE: 'step_complete', SUMMARY_CHUNK: 'summary_chunk', + SUMMARY_RESET: 'summary_reset', // 总结阶段检测到新工具调用,重置已有内容 SESSION_TITLE: 'session_title', DONE: 'done', ERROR: 'error', diff --git a/MeAgent/src/contexts/AuthContext.js b/MeAgent/src/contexts/AuthContext.js index 5a67e82d..f7da4688 100644 --- a/MeAgent/src/contexts/AuthContext.js +++ b/MeAgent/src/contexts/AuthContext.js @@ -26,24 +26,41 @@ export const AuthProvider = ({ children }) => { // 先检查本地是否有登录状态 const storedUser = await authService.getStoredUser(); - if (storedUser) { + const hasToken = await authService.getAccessToken(); + + if (storedUser && hasToken) { + // 有本地用户信息和 token,先恢复登录状态 setUser(storedUser); setIsLoggedIn(true); + console.log('[AuthContext] 从本地恢复登录状态:', storedUser.username); - // 然后尝试从服务器获取最新用户信息 - const serverUser = await authService.getCurrentUser(); - if (serverUser) { - setUser(serverUser); - // 获取订阅信息 - const subInfo = await authService.getSubscription(); - if (subInfo.success) { - setSubscription(subInfo.data || subInfo.subscription); + // 然后在后台尝试从服务器验证并获取最新用户信息 + // 使用 setTimeout 确保 UI 先渲染 + setTimeout(async () => { + try { + const serverUser = await authService.getCurrentUser(); + if (serverUser) { + setUser(serverUser); + console.log('[AuthContext] 服务器验证成功,更新用户信息'); + // 获取订阅信息 + const subInfo = await authService.getSubscription(); + if (subInfo.success) { + setSubscription(subInfo.data || subInfo.subscription); + } + } else { + // 服务器验证失败,但保留本地登录状态 + // 用户可以继续使用,下次操作时如果需要会提示重新登录 + console.warn('[AuthContext] 服务器验证失败,保留本地登录状态'); + } + } catch (error) { + // 网络错误等,保留本地状态 + console.warn('[AuthContext] 后台验证出错,保留本地登录状态:', error.message); } - } else { - // 服务器验证失败,清除本地状态 - setUser(null); - setIsLoggedIn(false); - } + }, 100); + } else if (storedUser) { + // 有用户信息但没有 token,需要重新登录 + console.log('[AuthContext] 没有有效 token,需要重新登录'); + await authService.clearLocalAuth(); } } catch (error) { console.error('[AuthContext] 初始化失败:', error); diff --git a/MeAgent/src/hooks/useAgentChat.js b/MeAgent/src/hooks/useAgentChat.js index efb0681b..a79d7315 100644 --- a/MeAgent/src/hooks/useAgentChat.js +++ b/MeAgent/src/hooks/useAgentChat.js @@ -212,6 +212,15 @@ export const useAgentChat = () => { })); break; + case SSEEventTypes.SUMMARY_RESET: + // 总结阶段检测到新工具调用,重置已有内容 + // 这发生在模型在总结过程中又输出了工具调用标签的情况 + console.log('[useAgentChat] 收到 summary_reset 事件,重置响应内容'); + responseContentRef.current = ''; + // 移除已有的 AGENT_RESPONSE 消息 + dispatch(removeMessagesByType(MessageTypes.AGENT_RESPONSE)); + break; + case SSEEventTypes.SUMMARY_CHUNK: // 总结流式输出 if (data?.content) { diff --git a/MeAgent/src/screens/Agent/AgentChatScreen.js b/MeAgent/src/screens/Agent/AgentChatScreen.js index 19210204..a7249e37 100644 --- a/MeAgent/src/screens/Agent/AgentChatScreen.js +++ b/MeAgent/src/screens/Agent/AgentChatScreen.js @@ -44,14 +44,15 @@ import { /** * 头部导航栏 */ -const Header = ({ title, onMenuPress, onNewPress }) => ( +const Header = ({ title, onHistoryPress, onNewPress }) => ( - + {/* 使用时钟图标表示历史记录 */} + 🕐 @@ -68,7 +69,8 @@ const Header = ({ title, onMenuPress, onNewPress }) => ( onPress={onNewPress} activeOpacity={0.7} > - + + {/* 使用铅笔图标表示新建对话 */} + ✏️ ); @@ -201,7 +203,7 @@ const AgentChatScreen = ({ navigation }) => { {/* 头部 */}
diff --git a/MeAgent/src/screens/Community/ChannelList.js b/MeAgent/src/screens/Community/ChannelList.js index 62a4c389..7e7099fc 100644 --- a/MeAgent/src/screens/Community/ChannelList.js +++ b/MeAgent/src/screens/Community/ChannelList.js @@ -345,6 +345,13 @@ const ChannelList = ({ navigation }) => { } showsVerticalScrollIndicator={false} contentContainerStyle={styles.listContent} + // 性能优化 - 防止真机渲染问题 + initialNumToRender={20} + maxToRenderPerBatch={20} + windowSize={10} + removeClippedSubviews={false} + // 确保所有 section 都渲染 + getItemLayout={undefined} /> ); diff --git a/MeAgent/src/screens/StockDetail/StockDetailScreen.js b/MeAgent/src/screens/StockDetail/StockDetailScreen.js index 912de249..22a15dd3 100644 --- a/MeAgent/src/screens/StockDetail/StockDetailScreen.js +++ b/MeAgent/src/screens/StockDetail/StockDetailScreen.js @@ -286,7 +286,7 @@ const StockDetailScreen = () => { return ( - {/* 价格头部 - Wind 风格(固定在顶部) */} + {/* 价格头部 - 固定在顶部,使用 WebSocket 实时行情 */} { isRealtime={wsConnected && !!realtimeQuote?.current_price} /> - {/* 图表类型切换 */} - - - {/* 图表区域 */} - - {chartType === 'minute' ? ( - - ) : ( - - )} - - - {/* 5档盘口 + 行情数据 + 相关信息(可滚动区域) */} + {/* 可滚动区域 - 包含图表和其他内容 */} + {/* 图表类型切换 */} + + + {/* 图表区域 */} + + {chartType === 'minute' ? ( + + ) : ( + + )} + + {/* 5档盘口 - 优先 WebSocket 实时数据,降级到 API 数据 */} {chartType === 'minute' && ( { {/* 相关信息 Tab */} - {/* 相关信息内容(给予固定最小高度,内部自己滚动) */} - + {/* 相关信息内容 */} + {renderInfoContent()} diff --git a/MeAgent/src/screens/Watchlist/AddWatchlistModal.js b/MeAgent/src/screens/Watchlist/AddWatchlistModal.js index df20d483..b4990f2c 100644 --- a/MeAgent/src/screens/Watchlist/AddWatchlistModal.js +++ b/MeAgent/src/screens/Watchlist/AddWatchlistModal.js @@ -3,8 +3,8 @@ * 支持搜索股票并添加到自选 */ -import React, { useState, useCallback, useRef } from 'react'; -import { StyleSheet, Keyboard, ActivityIndicator } from 'react-native'; +import React, { useState, useCallback, useRef, useMemo, memo } from 'react'; +import { Keyboard, ActivityIndicator, Dimensions } from 'react-native'; import { Modal, Box, @@ -18,24 +18,33 @@ import { useToast, } from 'native-base'; import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; import { stockDetailService } from '../../services/stockService'; import { useWatchlist } from '../../hooks/useWatchlist'; -// 搜索结果项组件 -const SearchResultItem = ({ item, onAdd, isAdding, isInWatchlist }) => { +const { height: SCREEN_HEIGHT } = Dimensions.get('window'); + +// 列表项高度常量(用于 getItemLayout 优化) +const ITEM_HEIGHT = 56; + +// 搜索结果项组件 - 使用 memo 避免不必要的重渲染 +const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => { const { stock_code, stock_name, industry } = item; - const alreadyAdded = isInWatchlist(stock_code); + + const handlePress = useCallback(() => { + if (!alreadyAdded && !isAdding) { + onAdd(item); + } + }, [alreadyAdded, isAdding, onAdd, item]); return ( !alreadyAdded && !isAdding && onAdd(item)} + onPress={handlePress} disabled={alreadyAdded || isAdding} > {({ pressed }) => ( { )} ); -}; +}); // 热门股票配置 const HOT_STOCKS = [ @@ -121,11 +130,24 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { const [isSearching, setIsSearching] = useState(false); const [addingCode, setAddingCode] = useState(null); const searchTimeoutRef = useRef(null); + const abortControllerRef = useRef(null); const toast = useToast(); - const { addStock, isInWatchlist } = useWatchlist({ autoLoad: false }); + const { addStock, isInWatchlist, stocks } = useWatchlist({ autoLoad: false }); - // 搜索股票 + // 创建已添加股票代码的 Set 用于快速查找 + const watchlistCodesSet = useMemo(() => { + const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code; + return new Set(stocks.map(s => normalizeCode(s.stock_code))); + }, [stocks]); + + // 检查是否在自选中(使用 Set 优化查找) + const checkInWatchlist = useCallback((stockCode) => { + const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code; + return watchlistCodesSet.has(normalizeCode(stockCode)); + }, [watchlistCodesSet]); + + // 搜索股票(带取消功能) const handleSearch = useCallback(async (text) => { setSearchText(text); @@ -134,14 +156,22 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { clearTimeout(searchTimeoutRef.current); } + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (!text.trim()) { setSearchResults([]); + setIsSearching(false); return; } - // 防抖搜索 + // 防抖搜索 - 200ms 更快响应 searchTimeoutRef.current = setTimeout(async () => { setIsSearching(true); + abortControllerRef.current = new AbortController(); + try { const response = await stockDetailService.searchStocks(text, 20); if (response.success && Array.isArray(response.data)) { @@ -150,12 +180,14 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { setSearchResults([]); } } catch (error) { - console.error('搜索股票失败:', error); - setSearchResults([]); + if (error.name !== 'AbortError') { + console.error('搜索股票失败:', error); + setSearchResults([]); + } } finally { setIsSearching(false); } - }, 300); + }, 200); }, []); // 添加到自选 @@ -189,35 +221,67 @@ const AddWatchlistModal = ({ isOpen, onClose }) => { } }, [addStock, toast]); - // 关闭弹窗 + // 关闭弹窗 - 清理资源 const handleClose = useCallback(() => { Keyboard.dismiss(); + // 取消进行中的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } setSearchText(''); setSearchResults([]); + setIsSearching(false); onClose?.(); }, [onClose]); - // 渲染列表数据 - const displayData = searchText.trim() ? searchResults : HOT_STOCKS; + // 渲染列表数据 - 使用 useMemo 缓存 + const displayData = useMemo(() => { + return searchText.trim() ? searchResults : HOT_STOCKS; + }, [searchText, searchResults]); + const listTitle = searchText.trim() ? '搜索结果' : '热门股票'; + // FlatList 性能优化配置 + const getItemLayout = useCallback((_, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), []); + + const keyExtractor = useCallback((item) => item.stock_code, []); + + const renderItem = useCallback(({ item }) => ( + + ), [handleAdd, addingCode, checkInWatchlist]); + return ( - + - - + {/* 头部 */} { {/* 股票列表 */} item.stock_code} - renderItem={({ item }) => ( - - )} + keyExtractor={keyExtractor} + renderItem={renderItem} + getItemLayout={getItemLayout} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={5} + removeClippedSubviews={true} ListEmptyComponent={ { ); }; -const styles = StyleSheet.create({}); - export default AddWatchlistModal; diff --git a/MeAgent/src/services/authService.js b/MeAgent/src/services/authService.js index c92b49b6..08054e10 100644 --- a/MeAgent/src/services/authService.js +++ b/MeAgent/src/services/authService.js @@ -84,9 +84,10 @@ export const authService = { /** * 获取当前用户信息 + * @param {boolean} clearOnFail - 失败时是否清除本地状态(默认 false) * @returns {Promise} 用户信息 */ - getCurrentUser: async () => { + getCurrentUser: async (clearOnFail = false) => { try { const response = await apiRequest('/api/account/user-info'); @@ -97,11 +98,18 @@ export const authService = { return response.user; } + // 如果返回 401/403,说明 token 无效 + if (response.status === 401 || response.status === 403) { + console.warn('[AuthService] Token 无效,需要重新登录'); + if (clearOnFail) { + await authService.clearLocalAuth(); + } + } + return null; } catch (error) { console.error('[AuthService] 获取用户信息失败:', error); - // 可能是未登录,清除本地状态 - await authService.clearLocalAuth(); + // 网络错误等,不清除本地状态,让用户可以继续使用 return null; } }, diff --git a/mcp_server.py b/mcp_server.py index b7585470..2272870b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -2934,129 +2934,312 @@ A股交易时间: 上午 9:30-11:30,下午 13:00-15:00 steps=[ToolCall(tool=s["tool"], arguments=s["arguments"], reason=s["reason"]) for s in plan_steps], ) - # 阶段3: 生成最终总结 - yield self._format_sse("status", {"stage": "summarizing", "message": "正在生成最终总结..."}) - - # 收集成功的结果 - successful_results = [r for r in step_results if r.status == "success"] - - # 初始化 final_summary + # 阶段3: 生成最终总结(支持在总结中检测到工具调用后继续执行) + max_summary_iterations = 3 # 总结阶段最多迭代次数,防止无限循环 + summary_iteration = 0 final_summary = "" - if not successful_results and step_index == 0: - # 如果没有执行任何工具(模型直接回复),使用模型的回复 - if assistant_message and assistant_message.content: - final_summary = assistant_message.content - # 流式发送(虽然已经是完整的,但保持前端兼容) + while summary_iteration < max_summary_iterations: + summary_iteration += 1 + logger.info(f"[Summary] 开始第 {summary_iteration} 轮总结生成") + + yield self._format_sse("status", {"stage": "summarizing", "message": f"正在生成最终总结{'(第 ' + str(summary_iteration) + ' 轮)' if summary_iteration > 1 else ''}..."}) + + # 收集成功的结果 + successful_results = [r for r in step_results if r.status == "success"] + + # 本轮是否需要继续执行工具调用 + need_more_tool_calls = False + detected_tool_calls = [] + + if not successful_results and step_index == 0: + # 如果没有执行任何工具(模型直接回复),使用模型的回复 + if assistant_message and assistant_message.content: + final_summary = assistant_message.content + # 流式发送(虽然已经是完整的,但保持前端兼容) + yield self._format_sse("summary_chunk", {"content": final_summary}) + else: + final_summary = "抱歉,我无法处理您的请求。" + yield self._format_sse("summary_chunk", {"content": final_summary}) + elif not successful_results: + # 所有步骤都失败 + final_summary = "很抱歉,所有步骤都执行失败,无法生成分析报告。" yield self._format_sse("summary_chunk", {"content": final_summary}) else: - final_summary = "抱歉,我无法处理您的请求。" - yield self._format_sse("summary_chunk", {"content": final_summary}) - elif not successful_results: - # 所有步骤都失败 - final_summary = "很抱歉,所有步骤都执行失败,无法生成分析报告。" - yield self._format_sse("summary_chunk", {"content": final_summary}) - else: - # 有成功的工具调用,使用流式 API 生成最终回复 - try: - # 使用流式 API 生成最终回复(不再传入 tools,让模型生成文本回复) - summary_stream = llm_client.chat.completions.create( - model=llm_model, - messages=messages, # messages 已包含所有工具调用历史 - temperature=0.7, - max_tokens=llm_max_tokens, - stream=True, # 启用流式输出 - ) + # 有成功的工具调用,使用流式 API 生成最终回复 + try: + # 使用流式 API 生成最终回复(不再传入 tools,让模型生成文本回复) + summary_stream = llm_client.chat.completions.create( + model=llm_model, + messages=messages, # messages 已包含所有工具调用历史 + temperature=0.7, + max_tokens=llm_max_tokens, + stream=True, # 启用流式输出 + ) - # 状态机处理 ... 标签 - # 状态: "normal" - 正常内容, "thinking" - 思考内容 - parse_state = "normal" - buffer = "" - thinking_content = "" - thinking_sent = False # 标记是否已发送过 thinking 事件 + # 状态机处理 ... 标签和 ... 标签 + # 状态: "normal" - 正常内容, "thinking" - 思考内容 + parse_state = "normal" + buffer = "" + thinking_content = "" + thinking_sent = False # 标记是否已发送过 thinking 事件 + accumulated_content = "" # 累积全部内容用于检测工具调用 - # 逐块发送总结内容 - for chunk in summary_stream: - if chunk.choices and chunk.choices[0].delta.content: - content_chunk = chunk.choices[0].delta.content - buffer += content_chunk + # 逐块发送总结内容 + for chunk in summary_stream: + if chunk.choices and chunk.choices[0].delta.content: + content_chunk = chunk.choices[0].delta.content + buffer += content_chunk + accumulated_content += content_chunk - # 处理 buffer 中的内容 - while buffer: - if parse_state == "normal": - # 查找 开始标签 - think_start = buffer.find("") - if think_start != -1: - # 发送 之前的内容 - if think_start > 0: - normal_content = buffer[:think_start] - final_summary += normal_content - yield self._format_sse("summary_chunk", {"content": normal_content}) - # 切换到思考状态 - parse_state = "thinking" - buffer = buffer[think_start + 7:] # 跳过 - if not thinking_sent: - yield self._format_sse("thinking_start", {"message": "正在深度思考..."}) - thinking_sent = True - else: - # 没有 标签,检查是否可能是不完整的标签 - if buffer.endswith("<") or buffer.endswith("' in accumulated_content and '' in accumulated_content: + logger.info(f"[Summary] 检测到工具调用标签,准备解析") + detected_tool_calls = self._parse_text_tool_calls(accumulated_content) + if detected_tool_calls: + logger.info(f"[Summary] 解析到 {len(detected_tool_calls)} 个工具调用: {detected_tool_calls}") + need_more_tool_calls = True + # 发送提示信息 + yield self._format_sse("status", {"stage": "executing", "message": f"模型需要继续调用 {len(detected_tool_calls)} 个工具..."}) + break # 中断流式输出,准备执行工具调用 + + # 处理 buffer 中的内容 + while buffer: + if parse_state == "normal": + # 查找 开始标签 + think_start = buffer.find("") + # 查找 开始标签(不完整时暂存) + tool_call_start = buffer.find("") + + if think_start != -1: + # 发送 之前的内容 + if think_start > 0: + normal_content = buffer[:think_start] + # 过滤掉可能包含的工具调用标签 + if '' not in normal_content: + final_summary += normal_content + yield self._format_sse("summary_chunk", {"content": normal_content}) + # 切换到思考状态 + parse_state = "thinking" + buffer = buffer[think_start + 7:] # 跳过 + if not thinking_sent: + yield self._format_sse("thinking_start", {"message": "正在深度思考..."}) + thinking_sent = True + elif tool_call_start != -1: + # 发送 之前的内容 + if tool_call_start > 0: + normal_content = buffer[:tool_call_start] + final_summary += normal_content + yield self._format_sse("summary_chunk", {"content": normal_content}) + # 保留工具调用标签部分,等待完整内容 + buffer = buffer[tool_call_start:] + break # 等待更多内容 else: - # 发送全部内容 - final_summary += buffer - yield self._format_sse("summary_chunk", {"content": buffer}) - buffer = "" + # 没有特殊标签,检查是否可能是不完整的标签 + incomplete_patterns = [ + "<", " 结束标签 - think_end = buffer.find("") - if think_end != -1: - # 发送思考内容 - if think_end > 0: - think_chunk = buffer[:think_end] - thinking_content += think_chunk - yield self._format_sse("thinking_chunk", {"content": think_chunk}) - # 切换回正常状态 - parse_state = "normal" - buffer = buffer[think_end + 8:] # 跳过 - yield self._format_sse("thinking_end", {"content": thinking_content}) - else: - # 没有 标签,检查是否可能是不完整的标签 - if buffer.endswith("<") or buffer.endswith(" 结束标签 + think_end = buffer.find("") + if think_end != -1: + # 发送思考内容 + if think_end > 0: + think_chunk = buffer[:think_end] + thinking_content += think_chunk + yield self._format_sse("thinking_chunk", {"content": think_chunk}) + # 切换回正常状态 + parse_state = "normal" + buffer = buffer[think_end + 8:] # 跳过 + yield self._format_sse("thinking_end", {"content": thinking_content}) else: - # 发送全部思考内容 - thinking_content += buffer - yield self._format_sse("thinking_chunk", {"content": buffer}) - buffer = "" + # 没有 标签,检查是否可能是不完整的标签 + if buffer.endswith("<") or buffer.endswith("= max_summary_iterations: + logger.warning(f"[Summary] 达到最大迭代次数 {max_summary_iterations},停止工具调用循环") # 过滤掉可能残留的工具调用标签(MiniMax 等模型可能在总结中输出工具调用) final_summary = self._filter_tool_call_tags(final_summary)